Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
577139c626 | ||
|
|
c5fb6281f0 | ||
|
|
f99812443c | ||
|
|
b898c6d0ea | ||
|
|
9e7045eaec | ||
|
|
a17ac02061 |
@@ -219,11 +219,11 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
- [x] `Instruction` — `session/instruction.ts`
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
- [x] `Storage` — `storage/storage.ts`
|
||||
- [x] `ShareNext` — `share/share-next.ts`
|
||||
|
||||
Still open:
|
||||
|
||||
- [ ] `SessionTodo` — `session/todo.ts`
|
||||
- [ ] `ShareNext` — `share/share-next.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts`
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||
|
||||
@@ -336,4 +336,6 @@ For each service, the migration is roughly:
|
||||
|
||||
### Migration log
|
||||
|
||||
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
|
||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EOL } from "os"
|
||||
import { basename } from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../../../agent/agent"
|
||||
import { Provider } from "../../../provider/provider"
|
||||
import { Session } from "../../../session"
|
||||
@@ -158,13 +159,15 @@ async function createToolContext(agent: Agent.Info) {
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset })
|
||||
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
|
||||
return Effect.sync(() => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, ruleset)
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Instance } from "../../project/instance"
|
||||
import { ShareNext } from "../../share/share-next"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
|
||||
export type ShareData =
|
||||
@@ -100,7 +101,7 @@ export const ImportCommand = cmd({
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(args.file)
|
||||
if (!slug) {
|
||||
const baseUrl = await ShareNext.url()
|
||||
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
@@ -108,7 +109,7 @@ export const ImportCommand = cmd({
|
||||
|
||||
const parsed = new URL(args.file)
|
||||
const baseUrl = parsed.origin
|
||||
const req = await ShareNext.request()
|
||||
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
|
||||
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
|
||||
@@ -34,7 +34,7 @@ export namespace FileTime {
|
||||
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
|
||||
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
|
||||
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
|
||||
@@ -103,8 +103,8 @@ export namespace FileTime {
|
||||
)
|
||||
})
|
||||
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
|
||||
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
|
||||
})
|
||||
|
||||
return Service.of({ read, get, assert, withLock })
|
||||
@@ -128,6 +128,6 @@ export namespace FileTime {
|
||||
}
|
||||
|
||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
||||
return runPromise((s) => s.withLock(filepath, fn))
|
||||
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,6 +540,8 @@ export namespace LSP {
|
||||
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
|
||||
|
||||
export namespace Diagnostic {
|
||||
const MAX_PER_FILE = 20
|
||||
|
||||
export function pretty(diagnostic: LSPClient.Diagnostic) {
|
||||
const severityMap = {
|
||||
1: "ERROR",
|
||||
@@ -554,5 +556,14 @@ export namespace LSP {
|
||||
|
||||
return `${severity} [${line}:${col}] ${diagnostic.message}`
|
||||
}
|
||||
|
||||
export function report(file: string, issues: LSPClient.Diagnostic[]) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) return ""
|
||||
const limited = errors.slice(0, MAX_PER_FILE)
|
||||
const more = errors.length - MAX_PER_FILE
|
||||
const suffix = more > 0 ? `\n... and ${more} more` : ""
|
||||
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { Log } from "@/util/log"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
await Plugin.init()
|
||||
ShareNext.init()
|
||||
void AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
|
||||
Format.init()
|
||||
await LSP.init()
|
||||
File.init()
|
||||
|
||||
@@ -94,7 +94,7 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await SessionStatus.list()
|
||||
const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
|
||||
return c.json(Object.fromEntries(result))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -103,6 +103,12 @@ export namespace SessionPrompt {
|
||||
const state = yield* SessionRunState.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
|
||||
const run = {
|
||||
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(EffectLogger.layer))),
|
||||
fork: <A, E>(effect: Effect.Effect<A, E>) => Effect.runFork(effect.pipe(Effect.provide(EffectLogger.layer))),
|
||||
}
|
||||
|
||||
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
||||
yield* elog.info("cancel", { sessionID })
|
||||
yield* state.cancel(sessionID)
|
||||
@@ -358,7 +364,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: (val) =>
|
||||
Effect.runPromise(
|
||||
run.promise(
|
||||
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||
if (!["running", "pending"].includes(match.state.status)) return match
|
||||
return {
|
||||
@@ -374,14 +380,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
),
|
||||
ask: (req) =>
|
||||
Effect.runPromise(
|
||||
permission.ask({
|
||||
permission
|
||||
.ask({
|
||||
...req,
|
||||
sessionID: input.session.id,
|
||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
for (const item of yield* registry.tools({
|
||||
@@ -395,7 +401,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema as any),
|
||||
execute(args, options) {
|
||||
return Effect.runPromise(
|
||||
return run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, options)
|
||||
yield* plugin.trigger(
|
||||
@@ -403,7 +409,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
{ args },
|
||||
)
|
||||
const result = yield* Effect.promise(() => item.execute(args, ctx))
|
||||
const result = yield* item.execute(args, ctx)
|
||||
const output = {
|
||||
...result,
|
||||
attachments: result.attachments?.map((attachment) => ({
|
||||
@@ -436,7 +442,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const transformed = ProviderTransform.schema(input.model, schema)
|
||||
item.inputSchema = jsonSchema(transformed)
|
||||
item.execute = (args, opts) =>
|
||||
Effect.runPromise(
|
||||
run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, opts)
|
||||
yield* plugin.trigger(
|
||||
@@ -444,7 +450,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }))
|
||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
|
||||
execute(args, opts),
|
||||
)
|
||||
@@ -576,63 +582,64 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
let error: Error | undefined
|
||||
const result = yield* Effect.promise((signal) =>
|
||||
taskTool
|
||||
.execute(taskArgs, {
|
||||
agent: task.agent,
|
||||
messageID: assistantMessage.id,
|
||||
sessionID,
|
||||
abort: signal,
|
||||
callID: part.callID,
|
||||
extra: { bypassAgentCheck: true, promptOps },
|
||||
messages: msgs,
|
||||
metadata(val: { title?: string; metadata?: Record<string, any> }) {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
part = yield* sessions.updatePart({
|
||||
...part,
|
||||
type: "tool",
|
||||
state: { ...part.state, ...val },
|
||||
} satisfies MessageV2.ToolPart)
|
||||
}),
|
||||
)
|
||||
},
|
||||
ask(req: any) {
|
||||
return Effect.runPromise(
|
||||
permission.ask({
|
||||
...req,
|
||||
sessionID,
|
||||
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
error = e instanceof Error ? e : new Error(String(e))
|
||||
const taskAbort = new AbortController()
|
||||
const result = yield* taskTool
|
||||
.execute(taskArgs, {
|
||||
agent: task.agent,
|
||||
messageID: assistantMessage.id,
|
||||
sessionID,
|
||||
abort: taskAbort.signal,
|
||||
callID: part.callID,
|
||||
extra: { bypassAgentCheck: true, promptOps },
|
||||
messages: msgs,
|
||||
metadata(val: { title?: string; metadata?: Record<string, any> }) {
|
||||
return run.promise(
|
||||
Effect.gen(function* () {
|
||||
part = yield* sessions.updatePart({
|
||||
...part,
|
||||
type: "tool",
|
||||
state: { ...part.state, ...val },
|
||||
} satisfies MessageV2.ToolPart)
|
||||
}),
|
||||
)
|
||||
},
|
||||
ask: (req: any) =>
|
||||
permission
|
||||
.ask({
|
||||
...req,
|
||||
sessionID,
|
||||
ruleset: Permission.merge(taskAgent.permission, session.permission ?? []),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const defect = Cause.squash(cause)
|
||||
error = defect instanceof Error ? defect : new Error(String(defect))
|
||||
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
||||
return undefined
|
||||
return Effect.void
|
||||
}),
|
||||
).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.gen(function* () {
|
||||
assistantMessage.finish = "tool-calls"
|
||||
assistantMessage.time.completed = Date.now()
|
||||
yield* sessions.updateMessage(assistantMessage)
|
||||
if (part.state.status === "running") {
|
||||
yield* sessions.updatePart({
|
||||
...part,
|
||||
state: {
|
||||
status: "error",
|
||||
error: "Cancelled",
|
||||
time: { start: part.state.time.start, end: Date.now() },
|
||||
metadata: part.state.metadata,
|
||||
input: part.state.input,
|
||||
},
|
||||
} satisfies MessageV2.ToolPart)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.gen(function* () {
|
||||
taskAbort.abort()
|
||||
assistantMessage.finish = "tool-calls"
|
||||
assistantMessage.time.completed = Date.now()
|
||||
yield* sessions.updateMessage(assistantMessage)
|
||||
if (part.state.status === "running") {
|
||||
yield* sessions.updatePart({
|
||||
...part,
|
||||
state: {
|
||||
status: "error",
|
||||
error: "Cancelled",
|
||||
time: { start: part.state.time.start, end: Date.now() },
|
||||
metadata: part.state.metadata,
|
||||
input: part.state.input,
|
||||
},
|
||||
} satisfies MessageV2.ToolPart)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const attachments = result?.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
@@ -855,7 +862,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
output += chunk
|
||||
if (part.state.status === "running") {
|
||||
part.state.metadata = { output, description: "" }
|
||||
void Effect.runFork(sessions.updatePart(part))
|
||||
void run.fork(sessions.updatePart(part))
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1037,19 +1044,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
|
||||
|
||||
const { read } = yield* registry.named()
|
||||
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
|
||||
Effect.promise((signal: AbortSignal) =>
|
||||
read.execute(args, {
|
||||
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) => {
|
||||
const controller = new AbortController()
|
||||
return read
|
||||
.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: signal,
|
||||
abort: controller.signal,
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true, ...extra },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}),
|
||||
)
|
||||
metadata: () => {},
|
||||
ask: () => Effect.void,
|
||||
})
|
||||
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
|
||||
}
|
||||
|
||||
if (part.mime === "text/plain") {
|
||||
let offset: number | undefined
|
||||
@@ -1655,9 +1664,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
|
||||
const promptOps: TaskPromptOps = {
|
||||
cancel: (sessionID) => Effect.runFork(cancel(sessionID)),
|
||||
resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)),
|
||||
prompt: (input) => Effect.runPromise(prompt(input)),
|
||||
cancel: (sessionID) => run.fork(cancel(sessionID)),
|
||||
resolvePromptParts: (template) => run.promise(resolvePromptParts(template)),
|
||||
prompt: (input) => run.promise(prompt(input)),
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
@@ -86,17 +85,4 @@ export namespace SessionStatus {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.get(sessionID))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function set(sessionID: SessionID, status: Info) {
|
||||
return runPromise((svc) => svc.set(sessionID, status))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
|
||||
import { Account } from "@/account"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Session } from "@/session"
|
||||
@@ -348,26 +347,4 @@ export namespace ShareNext {
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function url() {
|
||||
return runPromise((svc) => svc.url())
|
||||
}
|
||||
|
||||
export async function request(): Promise<Req> {
|
||||
return runPromise((svc) => svc.request())
|
||||
}
|
||||
|
||||
export async function create(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.create(sessionID))
|
||||
}
|
||||
|
||||
export async function remove(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.remove(sessionID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,13 @@ const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
export const ApplyPatchTool = Tool.defineEffect(
|
||||
export const ApplyPatchTool = Tool.define(
|
||||
"apply_patch",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const format = yield* Format.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
|
||||
if (!params.patchText) {
|
||||
@@ -178,18 +179,16 @@ export const ApplyPatchTool = Tool.defineEffect(
|
||||
|
||||
// Check permissions if needed
|
||||
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: relativePaths,
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: relativePaths,
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
})
|
||||
|
||||
// Apply the changes
|
||||
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
|
||||
@@ -228,13 +227,13 @@ export const ApplyPatchTool = Tool.defineEffect(
|
||||
|
||||
if (edited) {
|
||||
yield* format.file(edited)
|
||||
Bus.publish(File.Event.Edited, { file: edited })
|
||||
yield* bus.publish(File.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
Bus.publish(FileWatcher.Event.Updated, update)
|
||||
yield* bus.publish(FileWatcher.Event.Updated, update)
|
||||
}
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
@@ -258,20 +257,13 @@ export const ApplyPatchTool = Tool.defineEffect(
|
||||
})
|
||||
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
|
||||
|
||||
// Report LSP errors for changed files
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
for (const change of fileChanges) {
|
||||
if (change.type === "delete") continue
|
||||
const target = change.movePath ?? change.filePath
|
||||
const normalized = AppFileSystem.normalizePath(target)
|
||||
const issues = diagnostics[normalized] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
const block = LSP.Diagnostic.report(target, diagnostics[AppFileSystem.normalizePath(target)] ?? [])
|
||||
if (!block) continue
|
||||
const rel = path.relative(Instance.worktree, target).replaceAll("\\", "/")
|
||||
output += `\n\nLSP errors detected in ${rel}, please fix:\n${block}`
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -288,9 +280,7 @@ export const ApplyPatchTool = Tool.defineEffect(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
async execute(params: z.infer<typeof PatchParams>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
|
||||
},
|
||||
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -226,25 +226,21 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan)
|
||||
if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
||||
return path.join(dir, "*")
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
}
|
||||
|
||||
if (scan.patterns.size === 0) return
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: {},
|
||||
})
|
||||
})
|
||||
|
||||
function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
@@ -294,7 +290,7 @@ const parser = lazy(async () => {
|
||||
})
|
||||
|
||||
// TODO: we may wanna rename this tool so it works better on other shells
|
||||
export const BashTool = Tool.defineEffect(
|
||||
export const BashTool = Tool.define(
|
||||
"bash",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
@@ -504,7 +500,7 @@ export const BashTool = Tool.defineEffect(
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tool } from "./tool"
|
||||
import * as McpExa from "./mcp-exa"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
|
||||
export const CodeSearchTool = Tool.defineEffect(
|
||||
export const CodeSearchTool = Tool.define(
|
||||
"codesearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -29,17 +29,15 @@ export const CodeSearchTool = Tool.defineEffect(
|
||||
}),
|
||||
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* McpExa.call(
|
||||
http,
|
||||
@@ -59,7 +57,7 @@ export const CodeSearchTool = Tool.defineEffect(
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,8 +19,7 @@ import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replaceAll("\r\n", "\n")
|
||||
@@ -42,11 +41,14 @@ const Parameters = z.object({
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
})
|
||||
|
||||
export const EditTool = Tool.defineEffect(
|
||||
export const EditTool = Tool.define(
|
||||
"edit",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const filetime = yield* FileTime.Service
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const format = yield* Format.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -69,12 +71,53 @@ export const EditTool = Tool.defineEffect(
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
yield* filetime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
const existed = await Filesystem.exists(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
await ctx.ask({
|
||||
yield* filetime.withLock(filePath, () =>
|
||||
Effect.gen(function* () {
|
||||
if (params.oldString === "") {
|
||||
const existed = yield* afs.existsSafe(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
yield* afs.writeWithDirs(filePath, params.newString)
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
yield* filetime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) throw new Error(`File ${filePath} not found`)
|
||||
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
yield* filetime.assert(ctx.sessionID, filePath)
|
||||
contentOld = yield* afs.readFileString(filePath)
|
||||
|
||||
const ending = detectLineEnding(contentOld)
|
||||
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
||||
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
||||
|
||||
contentNew = replace(contentOld, old, next, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
@@ -83,65 +126,26 @@ export const EditTool = Tool.defineEffect(
|
||||
diff,
|
||||
},
|
||||
})
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
|
||||
yield* afs.writeWithDirs(filePath, contentNew)
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
event: "change",
|
||||
})
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
return
|
||||
}
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) throw new Error(`File ${filePath} not found`)
|
||||
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
contentOld = await Filesystem.readText(filePath)
|
||||
|
||||
const ending = detectLineEnding(contentOld)
|
||||
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
|
||||
const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
|
||||
|
||||
contentNew = replace(contentOld, old, next, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
})
|
||||
contentNew = yield* afs.readFileString(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
yield* filetime.read(ctx.sessionID, filePath)
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
@@ -166,16 +170,8 @@ export const EditTool = Tool.defineEffect(
|
||||
yield* lsp.touchFile(filePath, true)
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const issues = diagnostics[normalizedFilePath] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE
|
||||
? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
|
||||
: ""
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
|
||||
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
@@ -186,7 +182,7 @@ export const EditTool = Tool.defineEffect(
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -11,7 +11,11 @@ type Options = {
|
||||
kind?: Kind
|
||||
}
|
||||
|
||||
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
||||
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
|
||||
ctx: Tool.Context,
|
||||
target?: string,
|
||||
options?: Options,
|
||||
) {
|
||||
if (!target) return
|
||||
|
||||
if (options?.bypass) return
|
||||
@@ -26,7 +30,7 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
|
||||
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
||||
: path.join(dir, "*").replaceAll("\\", "/")
|
||||
|
||||
await ctx.ask({
|
||||
yield* ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: [glob],
|
||||
always: [glob],
|
||||
@@ -35,12 +39,8 @@ export async function assertExternalDirectory(ctx: Tool.Context, target?: string
|
||||
parentDir: dir,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
|
||||
ctx: Tool.Context,
|
||||
target?: string,
|
||||
options?: Options,
|
||||
) {
|
||||
yield* Effect.promise(() => assertExternalDirectory(ctx, target, options))
|
||||
})
|
||||
|
||||
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
||||
return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
|
||||
export const GlobTool = Tool.defineEffect(
|
||||
export const GlobTool = Tool.define(
|
||||
"glob",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
@@ -28,17 +28,15 @@ export const GlobTool = Tool.defineEffect(
|
||||
}),
|
||||
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
},
|
||||
})
|
||||
|
||||
let search = params.path ?? Instance.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||
@@ -90,7 +88,7 @@ export const GlobTool = Tool.defineEffect(
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const GrepTool = Tool.defineEffect(
|
||||
export const GrepTool = Tool.define(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
@@ -32,18 +32,16 @@ export const GrepTool = Tool.defineEffect(
|
||||
throw new Error("pattern is required")
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "grep",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
include: params.include,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "grep",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
include: params.include,
|
||||
},
|
||||
})
|
||||
|
||||
let searchPath = params.path ?? Instance.directory
|
||||
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
|
||||
@@ -171,7 +169,7 @@ export const GrepTool = Tool.defineEffect(
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const InvalidTool = Tool.define("invalid", {
|
||||
description: "Do not use",
|
||||
parameters: z.object({
|
||||
tool: z.string(),
|
||||
error: z.string(),
|
||||
export const InvalidTool = Tool.define(
|
||||
"invalid",
|
||||
Effect.succeed({
|
||||
description: "Do not use",
|
||||
parameters: z.object({
|
||||
tool: z.string(),
|
||||
error: z.string(),
|
||||
}),
|
||||
execute: (params: { tool: string; error: string }) =>
|
||||
Effect.succeed({
|
||||
title: "Invalid Tool",
|
||||
output: `The arguments provided to the tool are invalid: ${params.error}`,
|
||||
metadata: {},
|
||||
}),
|
||||
}),
|
||||
async execute(params) {
|
||||
return {
|
||||
title: "Invalid Tool",
|
||||
output: `The arguments provided to the tool are invalid: ${params.error}`,
|
||||
metadata: {},
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ export const IGNORE_PATTERNS = [
|
||||
|
||||
const LIMIT = 100
|
||||
|
||||
export const ListTool = Tool.defineEffect(
|
||||
export const ListTool = Tool.define(
|
||||
"list",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
@@ -56,16 +56,14 @@ export const ListTool = Tool.defineEffect(
|
||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "list",
|
||||
patterns: [searchPath],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: searchPath,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "list",
|
||||
patterns: [searchPath],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: searchPath,
|
||||
},
|
||||
})
|
||||
|
||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
|
||||
@@ -130,7 +128,7 @@ export const ListTool = Tool.defineEffect(
|
||||
},
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ const operations = [
|
||||
"outgoingCalls",
|
||||
] as const
|
||||
|
||||
export const LspTool = Tool.defineEffect(
|
||||
export const LspTool = Tool.define(
|
||||
"lsp",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
@@ -42,7 +42,7 @@ export const LspTool = Tool.defineEffect(
|
||||
Effect.gen(function* () {
|
||||
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
|
||||
yield* assertExternalDirectoryEffect(ctx, file)
|
||||
yield* Effect.promise(() => ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} }))
|
||||
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
|
||||
|
||||
const uri = pathToFileURL(file).href
|
||||
const position = { file, line: args.line - 1, character: args.character - 1 }
|
||||
@@ -85,7 +85,7 @@ export const LspTool = Tool.defineEffect(
|
||||
metadata: { result },
|
||||
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import DESCRIPTION from "./multiedit.txt"
|
||||
import path from "path"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
export const MultiEditTool = Tool.defineEffect(
|
||||
export const MultiEditTool = Tool.define(
|
||||
"multiedit",
|
||||
Effect.gen(function* () {
|
||||
const editInfo = yield* EditTool
|
||||
@@ -37,16 +37,14 @@ export const MultiEditTool = Tool.defineEffect(
|
||||
Effect.gen(function* () {
|
||||
const results = []
|
||||
for (const [, entry] of params.edits.entries()) {
|
||||
const result = yield* Effect.promise(() =>
|
||||
edit.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: entry.oldString,
|
||||
newString: entry.newString,
|
||||
replaceAll: entry.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = yield* edit.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: entry.oldString,
|
||||
newString: entry.newString,
|
||||
replaceAll: entry.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
results.push(result)
|
||||
}
|
||||
@@ -57,7 +55,7 @@ export const MultiEditTool = Tool.defineEffect(
|
||||
},
|
||||
output: results.at(-1)!.output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ function getLastModel(sessionID: SessionID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PlanExitTool = Tool.defineEffect(
|
||||
export const PlanExitTool = Tool.define(
|
||||
"plan_exit",
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -74,7 +74,7 @@ export const PlanExitTool = Tool.defineEffect(
|
||||
output: "User approved switching to build agent. Wait for further instructions.",
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ type Metadata = {
|
||||
answers: Question.Answer[]
|
||||
}
|
||||
|
||||
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
|
||||
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
|
||||
"question",
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
@@ -39,7 +39,7 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
|
||||
answers,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ const parameters = z.object({
|
||||
limit: z.coerce.number().describe("The maximum number of lines to read (defaults to 2000)").optional(),
|
||||
})
|
||||
|
||||
export const ReadTool = Tool.defineEffect(
|
||||
export const ReadTool = Tool.define(
|
||||
"read",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
@@ -106,14 +106,12 @@ export const ReadTool = Tool.defineEffect(
|
||||
kind: stat?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [filepath],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [filepath],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
if (!stat) return yield* miss(filepath)
|
||||
|
||||
@@ -218,9 +216,7 @@ export const ReadTool = Tool.defineEffect(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
|
||||
},
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ import { LSP } from "../lsp"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Instruction } from "../session/instruction"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Bus } from "../bus"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -89,10 +90,12 @@ export namespace ToolRegistry {
|
||||
| FileTime.Service
|
||||
| Instruction.Service
|
||||
| AppFileSystem.Service
|
||||
| Bus.Service
|
||||
| HttpClient.HttpClient
|
||||
| ChildProcessSpawner
|
||||
| Ripgrep.Service
|
||||
| Format.Service
|
||||
| Truncate.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -100,7 +103,9 @@ export namespace ToolRegistry {
|
||||
const plugin = yield* Plugin.Service
|
||||
const agents = yield* Agent.Service
|
||||
const skill = yield* Skill.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
|
||||
const invalid = yield* InvalidTool
|
||||
const task = yield* TaskTool
|
||||
const read = yield* ReadTool
|
||||
const question = yield* QuestionTool
|
||||
@@ -127,23 +132,26 @@ export namespace ToolRegistry {
|
||||
id,
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
execute: async (args, toolCtx) => {
|
||||
const pluginCtx: PluginToolContext = {
|
||||
...toolCtx,
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
}
|
||||
const result = await def.execute(args as any, pluginCtx)
|
||||
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: {
|
||||
truncated: out.truncated,
|
||||
outputPath: out.truncated ? out.outputPath : undefined,
|
||||
},
|
||||
}
|
||||
},
|
||||
execute: (args, toolCtx) =>
|
||||
Effect.gen(function* () {
|
||||
const pluginCtx: PluginToolContext = {
|
||||
...toolCtx,
|
||||
ask: (req) => Effect.runPromise(toolCtx.ask(req)),
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
}
|
||||
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
|
||||
const agent = yield* Effect.promise(() => Agent.get(toolCtx.agent))
|
||||
const out = yield* truncate.output(result, {}, agent)
|
||||
return {
|
||||
title: "",
|
||||
output: out.truncated ? out.content : result,
|
||||
metadata: {
|
||||
truncated: out.truncated,
|
||||
outputPath: out.truncated ? out.outputPath : undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +182,7 @@ export namespace ToolRegistry {
|
||||
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
const tool = yield* Effect.all({
|
||||
invalid: Tool.init(InvalidTool),
|
||||
invalid: Tool.init(invalid),
|
||||
bash: Tool.init(bash),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(globtool),
|
||||
@@ -328,10 +336,12 @@ export namespace ToolRegistry {
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
})
|
||||
|
||||
export const SkillTool = Tool.defineEffect(
|
||||
export const SkillTool = Tool.define(
|
||||
"skill",
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
@@ -51,14 +51,12 @@ export const SkillTool = Tool.defineEffect(
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
@@ -94,7 +92,7 @@ export const SkillTool = Tool.defineEffect(
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -31,7 +31,7 @@ const parameters = z.object({
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.defineEffect(
|
||||
export const TaskTool = Tool.define(
|
||||
id,
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
@@ -41,17 +41,15 @@ export const TaskTool = Tool.defineEffect(
|
||||
const cfg = yield* config.get()
|
||||
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: id,
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: id,
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const next = yield* agent.get(params.subagent_type)
|
||||
@@ -178,9 +176,7 @@ export const TaskTool = Tool.defineEffect(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx))
|
||||
},
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ type Metadata = {
|
||||
todos: Todo.Info[]
|
||||
}
|
||||
|
||||
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
|
||||
export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
|
||||
"todowrite",
|
||||
Effect.gen(function* () {
|
||||
const todo = yield* Todo.Service
|
||||
@@ -20,29 +20,28 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
|
||||
return {
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
|
||||
await ctx.ask({
|
||||
permission: "todowrite",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "todowrite",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
await todo
|
||||
.update({
|
||||
yield* todo.update({
|
||||
sessionID: ctx.sessionID,
|
||||
todos: params.todos,
|
||||
})
|
||||
.pipe(Effect.runPromise)
|
||||
|
||||
return {
|
||||
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
output: JSON.stringify(params.todos, null, 2),
|
||||
metadata: {
|
||||
todos: params.todos,
|
||||
},
|
||||
}
|
||||
},
|
||||
return {
|
||||
title: `${params.todos.filter((x) => x.status !== "completed").length} todos`,
|
||||
output: JSON.stringify(params.todos, null, 2),
|
||||
metadata: {
|
||||
todos: params.todos,
|
||||
},
|
||||
}
|
||||
}),
|
||||
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -23,22 +23,21 @@ export namespace Tool {
|
||||
extra?: { [key: string]: any }
|
||||
messages: MessageV2.WithParts[]
|
||||
metadata(input: { title?: string; metadata?: M }): void
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface ExecuteResult<M extends Metadata = Metadata> {
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
}
|
||||
|
||||
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
execute(
|
||||
args: z.infer<Parameters>,
|
||||
ctx: Context,
|
||||
): Promise<{
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
}>
|
||||
execute(args: z.infer<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
}
|
||||
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
|
||||
@@ -74,48 +73,41 @@ export namespace Tool {
|
||||
return async () => {
|
||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
toolInfo.execute = async (args, ctx) => {
|
||||
try {
|
||||
toolInfo.parameters.parse(args)
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
throw new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
toolInfo.execute = (args, ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
try: () => toolInfo.parameters.parse(args),
|
||||
catch: (error) => {
|
||||
if (error instanceof z.ZodError && toolInfo.formatValidationError) {
|
||||
return new Error(toolInfo.formatValidationError(error), { cause: error })
|
||||
}
|
||||
return new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
)
|
||||
},
|
||||
})
|
||||
const result = yield* execute(args, ctx)
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
throw new Error(
|
||||
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const result = await execute(args, ctx)
|
||||
if (result.metadata.truncated !== undefined) {
|
||||
return result
|
||||
}
|
||||
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
}
|
||||
}
|
||||
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
|
||||
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
|
||||
return {
|
||||
...result,
|
||||
output: truncated.content,
|
||||
metadata: {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie)
|
||||
return toolInfo
|
||||
}
|
||||
}
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>(
|
||||
id: ID,
|
||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
||||
): Info<Parameters, Result> & { id: ID } {
|
||||
return {
|
||||
id,
|
||||
init: wrap(id, init),
|
||||
}
|
||||
}
|
||||
|
||||
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||
id: ID,
|
||||
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
|
||||
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
|
||||
|
||||
@@ -18,7 +18,7 @@ const parameters = z.object({
|
||||
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
|
||||
})
|
||||
|
||||
export const WebFetchTool = Tool.defineEffect(
|
||||
export const WebFetchTool = Tool.define(
|
||||
"webfetch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -33,18 +33,16 @@ export const WebFetchTool = Tool.defineEffect(
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "webfetch",
|
||||
patterns: [params.url],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
url: params.url,
|
||||
format: params.format,
|
||||
timeout: params.timeout,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "webfetch",
|
||||
patterns: [params.url],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
url: params.url,
|
||||
format: params.format,
|
||||
timeout: params.timeout,
|
||||
},
|
||||
})
|
||||
|
||||
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
|
||||
|
||||
@@ -153,7 +151,7 @@ export const WebFetchTool = Tool.defineEffect(
|
||||
default:
|
||||
return { output: content, title, metadata: {} }
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ const Parameters = z.object({
|
||||
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
|
||||
})
|
||||
|
||||
export const WebSearchTool = Tool.defineEffect(
|
||||
export const WebSearchTool = Tool.define(
|
||||
"websearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -36,20 +36,18 @@ export const WebSearchTool = Tool.defineEffect(
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "websearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
numResults: params.numResults,
|
||||
livecrawl: params.livecrawl,
|
||||
type: params.type,
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "websearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
numResults: params.numResults,
|
||||
livecrawl: params.livecrawl,
|
||||
type: params.type,
|
||||
contextMaxCharacters: params.contextMaxCharacters,
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* McpExa.call(
|
||||
http,
|
||||
@@ -70,7 +68,7 @@ export const WebSearchTool = Tool.defineEffect(
|
||||
title: `Web search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,15 +15,16 @@ import { Instance } from "../project/instance"
|
||||
import { trimDiff } from "./edit"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
|
||||
|
||||
export const WriteTool = Tool.defineEffect(
|
||||
export const WriteTool = Tool.define(
|
||||
"write",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const filetime = yield* FileTime.Service
|
||||
const bus = yield* Bus.Service
|
||||
const format = yield* Format.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
@@ -43,27 +44,23 @@ export const WriteTool = Tool.defineEffect(
|
||||
if (exists) yield* filetime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filepath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath,
|
||||
diff,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filepath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
yield* fs.writeWithDirs(filepath, params.content)
|
||||
yield* Effect.promise(() => Format.file(filepath))
|
||||
Bus.publish(File.Event.Edited, { file: filepath })
|
||||
yield* Effect.promise(() =>
|
||||
Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
}),
|
||||
)
|
||||
yield* format.file(filepath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filepath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
yield* filetime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = "Wrote file successfully."
|
||||
@@ -72,20 +69,16 @@ export const WriteTool = Tool.defineEffect(
|
||||
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
|
||||
let projectDiagnosticsCount = 0
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) continue
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE
|
||||
? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more`
|
||||
: ""
|
||||
if (file === normalizedFilepath) {
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filepath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
const current = file === normalizedFilepath
|
||||
if (!current && projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||
const block = LSP.Diagnostic.report(current ? filepath : file, issues)
|
||||
if (!block) continue
|
||||
if (current) {
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n${block}`
|
||||
continue
|
||||
}
|
||||
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||
projectDiagnosticsCount++
|
||||
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
output += `\n\nLSP errors detected in other files:\n${block}`
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -97,7 +90,7 @@ export const WriteTool = Tool.defineEffect(
|
||||
},
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ const filetime = Layer.succeed(
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -735,19 +735,12 @@ it.live(
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const { task } = yield* registry.named()
|
||||
const original = task.execute
|
||||
task.execute = async (_args, ctx) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await new Promise<void>(() => {})
|
||||
return {
|
||||
title: "",
|
||||
metadata: {
|
||||
sessionId: SessionID.make("task"),
|
||||
model: ref,
|
||||
},
|
||||
output: "",
|
||||
}
|
||||
}
|
||||
task.execute = (_args, ctx) =>
|
||||
Effect.callback<never>((resume) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
return Effect.sync(() => aborted.resolve())
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
|
||||
|
||||
const { prompt, chat } = yield* boot()
|
||||
@@ -1393,11 +1386,10 @@ function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
|
||||
const ready = defer<void>()
|
||||
const aborted = defer<void>()
|
||||
const original = tool.execute
|
||||
tool.execute = async (_args: any, ctx: any) => {
|
||||
tool.execute = (_args: any, ctx: any) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await new Promise<void>(() => {})
|
||||
return { title: "", metadata: {}, output: "" }
|
||||
return Effect.callback<never>(() => {})
|
||||
}
|
||||
const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
|
||||
return { ready, aborted, restore }
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Effect, Schedule } from "effect"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -94,12 +95,16 @@ describe("session.retry.delay", () => {
|
||||
parse: (err) => err as MessageV2.APIError,
|
||||
set: (info) =>
|
||||
Effect.promise(() =>
|
||||
SessionStatus.set(sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
AppRuntime.runPromise(
|
||||
SessionStatus.Service.use((svc) =>
|
||||
svc.set(sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -108,7 +113,7 @@ describe("session.retry.delay", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await SessionStatus.get(sessionID)).toMatchObject({
|
||||
expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "boom",
|
||||
|
||||
@@ -107,7 +107,7 @@ const filetime = Layer.succeed(
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,10 +7,13 @@ import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
||||
)
|
||||
|
||||
const baseCtx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -42,22 +45,23 @@ type AskInput = {
|
||||
}
|
||||
|
||||
type ToolCtx = typeof baseCtx & {
|
||||
ask: (input: AskInput) => Promise<void>
|
||||
ask: (input: AskInput) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||
const info = await runtime.runPromise(ApplyPatchTool)
|
||||
const tool = await info.init()
|
||||
return tool.execute(params, ctx)
|
||||
return Effect.runPromise(tool.execute(params, ctx))
|
||||
}
|
||||
|
||||
const makeCtx = () => {
|
||||
const calls: AskInput[] = []
|
||||
const ctx: ToolCtx = {
|
||||
...baseCtx,
|
||||
ask: async (input) => {
|
||||
calls.push(input)
|
||||
},
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
}),
|
||||
}
|
||||
|
||||
return { ctx, calls }
|
||||
|
||||
@@ -30,7 +30,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
Shell.acceptable.reset()
|
||||
@@ -109,10 +109,11 @@ const each = (name: string, fn: (item: { label: string; shell: string }) => Prom
|
||||
|
||||
const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
if (stop) throw stop
|
||||
},
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
if (stop) throw stop
|
||||
}),
|
||||
})
|
||||
|
||||
const mustTruncate = (result: {
|
||||
@@ -131,12 +132,14 @@ describe("tool.bash", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo test",
|
||||
description: "Echo test message",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo test",
|
||||
description: "Echo test message",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
expect(result.metadata.output).toContain("test")
|
||||
@@ -153,12 +156,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
@@ -174,12 +179,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
@@ -198,12 +205,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
||||
description: "Check PowerShell conditional",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
||||
description: "Check PowerShell conditional",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
@@ -226,12 +235,14 @@ describe("tool.bash permissions", () => {
|
||||
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
|
||||
const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${file}`,
|
||||
description: "Read wildcard path",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${file}`,
|
||||
description: "Read wildcard path",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
@@ -257,12 +268,14 @@ describe("tool.bash permissions", () => {
|
||||
const bash = await initBash()
|
||||
const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: `echo $(cat "${file}")`,
|
||||
description: "Read nested bash file",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo $(cat "${file}")`,
|
||||
description: "Read nested bash file",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
@@ -289,12 +302,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
|
||||
description: "Copy Windows ini",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
|
||||
description: "Copy Windows ini",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
@@ -316,12 +331,14 @@ describe("tool.bash permissions", () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
|
||||
await bash.execute(
|
||||
{
|
||||
command: `Write-Output $(Get-Content ${file})`,
|
||||
description: "Read nested PowerShell file",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Write-Output $(Get-Content ${file})`,
|
||||
description: "Read nested PowerShell file",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
@@ -347,12 +364,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "C:../outside.txt"',
|
||||
description: "Read drive-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "C:../outside.txt"',
|
||||
description: "Read drive-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
@@ -375,12 +394,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$HOME/.ssh/config"',
|
||||
description: "Read home config",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$HOME/.ssh/config"',
|
||||
description: "Read home config",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
@@ -404,12 +425,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PWD/../outside.txt"',
|
||||
description: "Read pwd-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PWD/../outside.txt"',
|
||||
description: "Read pwd-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
@@ -432,12 +455,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PSHOME/outside.txt"',
|
||||
description: "Read pshome file",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PSHOME/outside.txt"',
|
||||
description: "Read pshome file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
@@ -465,12 +490,14 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
|
||||
description: "Read Windows ini with missing env",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
|
||||
description: "Read Windows ini with missing env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
@@ -495,12 +522,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Get-Content $env:WINDIR/win.ini",
|
||||
description: "Read Windows ini from env",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Get-Content $env:WINDIR/win.ini",
|
||||
description: "Read Windows ini from env",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeDefined()
|
||||
@@ -524,12 +553,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
|
||||
description: "Read Windows ini from FileSystem provider",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`,
|
||||
description: "Read Windows ini from FileSystem provider",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
@@ -554,12 +585,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Get-Content ${env:WINDIR}/win.ini",
|
||||
description: "Read Windows ini from braced env",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Get-Content ${env:WINDIR}/win.ini",
|
||||
description: "Read Windows ini from braced env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]?.permission).toBe("external_directory")
|
||||
@@ -582,12 +615,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Set-Location C:/Windows",
|
||||
description: "Change location",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Set-Location C:/Windows",
|
||||
description: "Change location",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
@@ -611,12 +646,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Write-Output ('a' * 3)",
|
||||
description: "Write repeated text",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Write-Output ('a' * 3)",
|
||||
description: "Write repeated text",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
@@ -638,12 +675,14 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
@@ -661,13 +700,15 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: os.tmpdir(),
|
||||
description: "Echo from temp dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: os.tmpdir(),
|
||||
description: "Echo from temp dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
@@ -691,13 +732,15 @@ describe("tool.bash permissions", () => {
|
||||
for (const dir of forms(outerTmp.path)) {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
|
||||
@@ -724,13 +767,15 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/tmp",
|
||||
description: "Echo from Git Bash tmp",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/tmp",
|
||||
description: "Echo from Git Bash tmp",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]).toMatchObject({
|
||||
@@ -754,12 +799,14 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cat /tmp/opencode-does-not-exist",
|
||||
description: "Read Git Bash tmp file",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cat /tmp/opencode-does-not-exist",
|
||||
description: "Read Git Bash tmp file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]).toMatchObject({
|
||||
@@ -789,12 +836,14 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const filepath = path.join(outerTmp.path, "outside.txt")
|
||||
await expect(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
@@ -817,12 +866,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: `rm -rf ${path.join(tmp.path, "nested")}`,
|
||||
description: "Remove nested dir",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `rm -rf ${path.join(tmp.path, "nested")}`,
|
||||
description: "Remove nested dir",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const extDirReq = requests.find((r) => r.permission === "external_directory")
|
||||
expect(extDirReq).toBeUndefined()
|
||||
@@ -837,12 +888,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].always.length).toBeGreaterThan(0)
|
||||
@@ -858,12 +911,14 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
capture(requests),
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeUndefined()
|
||||
@@ -880,9 +935,11 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
bash.execute(
|
||||
{ command: "echo test > output.txt", description: "Redirect test output" },
|
||||
capture(requests, err),
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{ command: "echo test > output.txt", description: "Redirect test output" },
|
||||
capture(requests, err),
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
@@ -899,7 +956,7 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await bash.execute({ command: "ls -la", description: "List" }, capture(requests))
|
||||
await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests)))
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
expect(bashReq!.always[0]).toBe("ls *")
|
||||
@@ -916,24 +973,25 @@ describe("tool.bash abort", () => {
|
||||
const bash = await initBash()
|
||||
const controller = new AbortController()
|
||||
const collected: string[] = []
|
||||
const result = bash.execute(
|
||||
{
|
||||
command: `echo before && sleep 30`,
|
||||
description: "Long running command",
|
||||
},
|
||||
{
|
||||
...ctx,
|
||||
abort: controller.signal,
|
||||
metadata: (input) => {
|
||||
const output = (input.metadata as { output?: string })?.output
|
||||
if (output && output.includes("before") && !controller.signal.aborted) {
|
||||
collected.push(output)
|
||||
controller.abort()
|
||||
}
|
||||
const res = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo before && sleep 30`,
|
||||
description: "Long running command",
|
||||
},
|
||||
},
|
||||
{
|
||||
...ctx,
|
||||
abort: controller.signal,
|
||||
metadata: (input) => {
|
||||
const output = (input.metadata as { output?: string })?.output
|
||||
if (output && output.includes("before") && !controller.signal.aborted) {
|
||||
collected.push(output)
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
const res = await result
|
||||
expect(res.output).toContain("before")
|
||||
expect(res.output).toContain("User aborted the command")
|
||||
expect(collected.length).toBeGreaterThan(0)
|
||||
@@ -946,13 +1004,15 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Timeout test",
|
||||
timeout: 500,
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Timeout test",
|
||||
timeout: 500,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.output).toContain("started")
|
||||
expect(result.output).toContain("bash tool terminated command after exceeding timeout")
|
||||
@@ -965,12 +1025,14 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `echo stdout_msg && echo stderr_msg >&2`,
|
||||
description: "Stderr test",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo stdout_msg && echo stderr_msg >&2`,
|
||||
description: "Stderr test",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.output).toContain("stdout_msg")
|
||||
expect(result.output).toContain("stderr_msg")
|
||||
@@ -984,12 +1046,14 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `exit 42`,
|
||||
description: "Non-zero exit",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `exit 42`,
|
||||
description: "Non-zero exit",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.metadata.exit).toBe(42)
|
||||
},
|
||||
@@ -1002,18 +1066,20 @@ describe("tool.bash abort", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const updates: string[] = []
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `echo first && sleep 0.1 && echo second`,
|
||||
description: "Streaming test",
|
||||
},
|
||||
{
|
||||
...ctx,
|
||||
metadata: (input) => {
|
||||
const output = (input.metadata as { output?: string })?.output
|
||||
if (output) updates.push(output)
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo first && sleep 0.1 && echo second`,
|
||||
description: "Streaming test",
|
||||
},
|
||||
},
|
||||
{
|
||||
...ctx,
|
||||
metadata: (input) => {
|
||||
const output = (input.metadata as { output?: string })?.output
|
||||
if (output) updates.push(output)
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
expect(result.output).toContain("first")
|
||||
expect(result.output).toContain("second")
|
||||
@@ -1030,12 +1096,14 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 500
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
@@ -1050,12 +1118,14 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const byteCount = Truncate.MAX_BYTES + 10000
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: fill("bytes", byteCount),
|
||||
description: "Generate bytes exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: fill("bytes", byteCount),
|
||||
description: "Generate bytes exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
@@ -1069,12 +1139,14 @@ describe("tool.bash truncation", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
|
||||
expect(result.output).toContain("hello")
|
||||
@@ -1088,12 +1160,14 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 100
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines for file check",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines for file check",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
mustTruncate(result)
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
|
||||
const ctx = {
|
||||
@@ -17,7 +21,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -29,7 +33,9 @@ async function touch(file: string, time: number) {
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer))
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
||||
)
|
||||
|
||||
afterAll(async () => {
|
||||
await runtime.dispose()
|
||||
@@ -43,6 +49,12 @@ const resolve = () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const readFileTime = (sessionID: SessionID, filepath: string) =>
|
||||
runtime.runPromise(FileTime.Service.use((ft) => ft.read(sessionID, filepath)))
|
||||
|
||||
const subscribeBus = <D extends BusEvent.Definition>(def: D, callback: () => unknown) =>
|
||||
runtime.runPromise(Bus.Service.use((bus) => bus.subscribeCallback(def, callback)))
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
@@ -53,13 +65,15 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.metadata.diff).toContain("new content")
|
||||
@@ -78,13 +92,15 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
ctx,
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -100,21 +116,21 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(events).toContain("updated")
|
||||
@@ -133,16 +149,18 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Edit applied successfully")
|
||||
@@ -160,17 +178,19 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("not found")
|
||||
},
|
||||
@@ -187,13 +207,15 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
@@ -208,17 +230,19 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
@@ -235,13 +259,15 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("You must read file")
|
||||
},
|
||||
@@ -258,7 +284,7 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
@@ -267,13 +293,15 @@ describe("tool.edit", () => {
|
||||
// Try to edit with the new content
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
@@ -288,17 +316,19 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
newString: "qux",
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
newString: "qux",
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -315,22 +345,23 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(events).toContain("updated")
|
||||
@@ -349,16 +380,18 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -375,16 +408,18 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -403,13 +438,15 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
},
|
||||
ctx,
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
@@ -424,17 +461,19 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, dirpath)
|
||||
await readFileTime(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow("directory")
|
||||
},
|
||||
@@ -449,16 +488,18 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.metadata.filediff).toBeDefined()
|
||||
@@ -520,15 +561,17 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath,
|
||||
oldString: input.oldString,
|
||||
newString: input.newString,
|
||||
replaceAll: input.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
await readFileTime(ctx.sessionID, filePath)
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath,
|
||||
oldString: input.oldString,
|
||||
newString: input.newString,
|
||||
replaceAll: input.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
return await Bun.file(filePath).text()
|
||||
},
|
||||
@@ -661,30 +704,34 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
|
||||
// Two concurrent edits
|
||||
const promise1 = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "1",
|
||||
},
|
||||
ctx,
|
||||
const promise1 = Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "1",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
|
||||
const promise2 = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "2",
|
||||
},
|
||||
ctx,
|
||||
const promise2 = Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "2",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
|
||||
// Both should complete without error (though one might fail due to content mismatch)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import type { Tool } from "../../src/tool/tool"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { assertExternalDirectory } from "../../src/tool/external-directory"
|
||||
@@ -21,15 +22,21 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
|
||||
const glob = (p: string) =>
|
||||
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
|
||||
|
||||
function makeCtx() {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
}
|
||||
return { requests, ctx }
|
||||
}
|
||||
|
||||
describe("tool.assertExternalDirectory", () => {
|
||||
test("no-ops for empty target", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp",
|
||||
@@ -42,13 +49,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("no-ops for paths inside Instance.directory", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
@@ -61,13 +62,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("asks with a single canonical glob", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside/file.txt"
|
||||
@@ -87,13 +82,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("uses target directory when kind=directory", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside"
|
||||
@@ -113,13 +102,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("skips prompting when bypass=true", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
@@ -133,13 +116,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes Windows path variants to one glob", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -169,13 +146,7 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("uses drive root glob for root files", async () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
const { requests, ctx } = makeCtx()
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const root = path.parse(tmp.path).root
|
||||
|
||||
@@ -21,7 +21,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
@@ -32,13 +32,15 @@ describe("tool.grep", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "export",
|
||||
path: path.join(projectRoot, "src/tool"),
|
||||
include: "*.ts",
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
grep.execute(
|
||||
{
|
||||
pattern: "export",
|
||||
path: path.join(projectRoot, "src/tool"),
|
||||
include: "*.ts",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
expect(result.output).toContain("Found")
|
||||
@@ -56,12 +58,14 @@ describe("tool.grep", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.metadata.matches).toBe(0)
|
||||
expect(result.output).toBe("No files found")
|
||||
@@ -81,12 +85,14 @@ describe("tool.grep", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
@@ -49,7 +49,7 @@ describe("tool.question", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("tool.question", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* Effect.promise(() => tool.execute({ questions }, ctx)).pipe(Effect.forkScoped)
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const item = yield* pending(question)
|
||||
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
@@ -54,7 +54,7 @@ const run = Effect.fn("ReadToolTest.run")(function* (
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* Effect.promise(() => tool.execute(args, next))
|
||||
return yield* tool.execute(args, next)
|
||||
})
|
||||
|
||||
const exec = Effect.fn("ReadToolTest.exec")(function* (
|
||||
@@ -95,9 +95,10 @@ const asks = () => {
|
||||
items,
|
||||
next: {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
items.push(req)
|
||||
},
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
items.push(req)
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -226,17 +227,18 @@ describe("tool.read env file permissions", () => {
|
||||
let asked = false
|
||||
const next = {
|
||||
...ctx,
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, info.permission)
|
||||
if (rule.action === "ask" && req.permission === "read") {
|
||||
asked = true
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
for (const pattern of req.patterns) {
|
||||
const rule = Permission.evaluate(req.permission, pattern, info.permission)
|
||||
if (rule.action === "ask" && req.permission === "read") {
|
||||
asked = true
|
||||
}
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset: info.permission })
|
||||
}
|
||||
}
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset: info.permission })
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
yield* run({ filePath: path.join(dir, filename) }, next)
|
||||
|
||||
@@ -156,12 +156,13 @@ Use this skill.
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
}
|
||||
|
||||
const result = await tool.execute({ name: "tool-skill" }, ctx)
|
||||
const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx))
|
||||
const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill")
|
||||
const file = path.resolve(dir, "scripts", "demo.txt")
|
||||
|
||||
|
||||
@@ -194,25 +194,23 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
task_id: child.id,
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
task_id: child.id,
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
@@ -235,26 +233,25 @@ describe("tool.task", () => {
|
||||
const promptOps = stubOps()
|
||||
|
||||
const exec = (extra?: Record<string, any>) =>
|
||||
Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps, ...extra },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async (input) => {
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps, ...extra },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
},
|
||||
},
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
yield* exec()
|
||||
@@ -284,25 +281,23 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
task_id: "ses_missing",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
task_id: "ses_missing",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
@@ -326,24 +321,22 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "reviewer",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "reviewer",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps },
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
const child = yield* sessions.get(result.metadata.sessionId)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
|
||||
@@ -8,9 +9,9 @@ function makeTool(id: string, executeFn?: () => void) {
|
||||
return {
|
||||
description: "test tool",
|
||||
parameters: params,
|
||||
async execute() {
|
||||
execute() {
|
||||
executeFn?.()
|
||||
return { title: "test", output: "ok", metadata: {} }
|
||||
return Effect.succeed({ title: "test", output: "ok", metadata: {} })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,29 +21,34 @@ describe("Tool.define", () => {
|
||||
const original = makeTool("test")
|
||||
const originalExecute = original.execute
|
||||
|
||||
const tool = Tool.define("test-tool", original)
|
||||
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
||||
|
||||
await tool.init()
|
||||
await tool.init()
|
||||
await tool.init()
|
||||
await info.init()
|
||||
await info.init()
|
||||
await info.init()
|
||||
|
||||
expect(original.execute).toBe(originalExecute)
|
||||
})
|
||||
|
||||
test("function-defined tool returns fresh objects and is unaffected", async () => {
|
||||
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
|
||||
const info = await Effect.runPromise(
|
||||
Tool.define(
|
||||
"test-fn-tool",
|
||||
Effect.succeed(() => Promise.resolve(makeTool("test"))),
|
||||
),
|
||||
)
|
||||
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
const first = await info.init()
|
||||
const second = await info.init()
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
test("object-defined tool returns distinct objects per init() call", async () => {
|
||||
const tool = Tool.define("test-copy", makeTool("test"))
|
||||
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
||||
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
const first = await info.init()
|
||||
const second = await info.init()
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
|
||||
@@ -42,9 +42,8 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute(
|
||||
{ url: new URL("/image.png", url).toString(), format: "markdown" },
|
||||
ctx,
|
||||
const result = await Effect.runPromise(
|
||||
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
|
||||
)
|
||||
expect(result.output).toBe("Image fetched successfully")
|
||||
expect(result.attachments).toBeDefined()
|
||||
@@ -74,7 +73,9 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
|
||||
const result = await Effect.runPromise(
|
||||
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
|
||||
)
|
||||
expect(result.output).toContain("<svg")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
@@ -95,7 +96,9 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
|
||||
const result = await Effect.runPromise(
|
||||
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
|
||||
)
|
||||
expect(result.output).toBe("hello from webfetch")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Format } from "../../src/format"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
@@ -21,7 +23,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -29,7 +31,14 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer),
|
||||
Layer.mergeAll(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
Bus.layer,
|
||||
Format.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const init = Effect.fn("WriteToolTest.init")(function* () {
|
||||
@@ -42,7 +51,7 @@ const run = Effect.fn("WriteToolTest.run")(function* (
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* Effect.promise(() => tool.execute(args, next))
|
||||
return yield* tool.execute(args, next)
|
||||
})
|
||||
|
||||
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
|
||||
|
||||
@@ -316,29 +316,6 @@ export type EventCommandExecuted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceReady = {
|
||||
type: "workspace.ready"
|
||||
properties: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceFailed = {
|
||||
type: "workspace.failed"
|
||||
properties: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceStatus = {
|
||||
type: "workspace.status"
|
||||
properties: {
|
||||
workspaceID: string
|
||||
status: "connected" | "connecting" | "disconnected" | "error"
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type QuestionOption = {
|
||||
/**
|
||||
* Display text (1-5 words, concise)
|
||||
@@ -410,6 +387,29 @@ export type EventQuestionRejected = {
|
||||
}
|
||||
}
|
||||
|
||||
export type Todo = {
|
||||
/**
|
||||
* Brief description of the task
|
||||
*/
|
||||
content: string
|
||||
/**
|
||||
* Current status of the task: pending, in_progress, completed, cancelled
|
||||
*/
|
||||
status: string
|
||||
/**
|
||||
* Priority level of the task: high, medium, low
|
||||
*/
|
||||
priority: string
|
||||
}
|
||||
|
||||
export type EventTodoUpdated = {
|
||||
type: "todo.updated"
|
||||
properties: {
|
||||
sessionID: string
|
||||
todos: Array<Todo>
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionStatus =
|
||||
| {
|
||||
type: "idle"
|
||||
@@ -446,29 +446,6 @@ export type EventSessionCompacted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type Todo = {
|
||||
/**
|
||||
* Brief description of the task
|
||||
*/
|
||||
content: string
|
||||
/**
|
||||
* Current status of the task: pending, in_progress, completed, cancelled
|
||||
*/
|
||||
status: string
|
||||
/**
|
||||
* Priority level of the task: high, medium, low
|
||||
*/
|
||||
priority: string
|
||||
}
|
||||
|
||||
export type EventTodoUpdated = {
|
||||
type: "todo.updated"
|
||||
properties: {
|
||||
sessionID: string
|
||||
todos: Array<Todo>
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorktreeReady = {
|
||||
type: "worktree.ready"
|
||||
properties: {
|
||||
@@ -523,6 +500,29 @@ export type EventPtyDeleted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceReady = {
|
||||
type: "workspace.ready"
|
||||
properties: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceFailed = {
|
||||
type: "workspace.failed"
|
||||
properties: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventWorkspaceStatus = {
|
||||
type: "workspace.status"
|
||||
properties: {
|
||||
workspaceID: string
|
||||
status: "connected" | "connecting" | "disconnected" | "error"
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type OutputFormatText = {
|
||||
type: "text"
|
||||
}
|
||||
@@ -995,22 +995,22 @@ export type Event =
|
||||
| EventMcpToolsChanged
|
||||
| EventMcpBrowserOpenFailed
|
||||
| EventCommandExecuted
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventWorkspaceStatus
|
||||
| EventQuestionAsked
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
| EventTodoUpdated
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSessionCompacted
|
||||
| EventTodoUpdated
|
||||
| EventWorktreeReady
|
||||
| EventWorktreeFailed
|
||||
| EventPtyCreated
|
||||
| EventPtyUpdated
|
||||
| EventPtyExited
|
||||
| EventPtyDeleted
|
||||
| EventWorkspaceReady
|
||||
| EventWorkspaceFailed
|
||||
| EventWorkspaceStatus
|
||||
| EventMessageUpdated
|
||||
| EventMessageRemoved
|
||||
| EventMessagePartUpdated
|
||||
|
||||
+121
-121
@@ -7986,71 +7986,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.ready": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "workspace.ready"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "workspace.failed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "workspace.status"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceID": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk.*"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["connected", "connecting", "disconnected", "error"]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceID", "status"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"QuestionOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8201,6 +8136,50 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Brief description of the task",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"description": "Priority level of the task: high, medium, low",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["content", "status", "priority"]
|
||||
},
|
||||
"Event.todo.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "todo.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Todo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "todos"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"SessionStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8307,50 +8286,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"description": "Brief description of the task",
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
||||
"type": "string"
|
||||
},
|
||||
"priority": {
|
||||
"description": "Priority level of the task: high, medium, low",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["content", "status", "priority"]
|
||||
},
|
||||
"Event.todo.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "todo.updated"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sessionID": {
|
||||
"type": "string",
|
||||
"pattern": "^ses.*"
|
||||
},
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Todo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sessionID", "todos"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.worktree.ready": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8505,6 +8440,71 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.ready": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "workspace.ready"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.failed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "workspace.failed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.workspace.status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "workspace.status"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workspaceID": {
|
||||
"type": "string",
|
||||
"pattern": "^wrk.*"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["connected", "connecting", "disconnected", "error"]
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["workspaceID", "status"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"OutputFormatText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9937,15 +9937,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.command.executed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.ready"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.failed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.status"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.asked"
|
||||
},
|
||||
@@ -9955,6 +9946,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.status"
|
||||
},
|
||||
@@ -9964,9 +9958,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.worktree.ready"
|
||||
},
|
||||
@@ -9985,6 +9976,15 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.pty.deleted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.ready"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.failed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.workspace.status"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.message.updated"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user