mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:16:20 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53136cf57f |
@@ -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,6 +336,4 @@ 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,6 +1,5 @@
|
||||
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"
|
||||
@@ -159,15 +158,13 @@ async function createToolContext(agent: Agent.Info) {
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
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 })
|
||||
}
|
||||
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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 =
|
||||
@@ -101,7 +100,7 @@ export const ImportCommand = cmd({
|
||||
if (isUrl) {
|
||||
const slug = parseShareUrl(args.file)
|
||||
if (!slug) {
|
||||
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url()))
|
||||
const baseUrl = await ShareNext.url()
|
||||
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
@@ -109,7 +108,7 @@ export const ImportCommand = cmd({
|
||||
|
||||
const parsed = new URL(args.file)
|
||||
const baseUrl = parsed.origin
|
||||
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request()))
|
||||
const req = await ShareNext.request()
|
||||
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
|
||||
|
||||
const dataPath = req.api.data(slug)
|
||||
|
||||
@@ -55,7 +55,7 @@ export namespace EffectLogger {
|
||||
}
|
||||
})
|
||||
|
||||
export const layer = Logger.layer([logger], { mergeWithExisting: false })
|
||||
export const layer = Logger.layer([Logger.tracerLogger, logger], { mergeWithExisting: false })
|
||||
|
||||
export const create = (base: Fields = {}): Handle => ({
|
||||
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { CHANNEL, VERSION } from "@/installation/meta"
|
||||
|
||||
export namespace Observability {
|
||||
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT ?? (CHANNEL === "local" ? "http://127.0.0.1:27686" : undefined)
|
||||
export const enabled = !!base
|
||||
|
||||
const resource = {
|
||||
@@ -34,6 +34,7 @@ export namespace Observability {
|
||||
: Otlp.layerJson({
|
||||
baseUrl: base,
|
||||
loggerExportInterval: Duration.seconds(1),
|
||||
tracerExportInterval: Duration.seconds(1),
|
||||
loggerMergeWithExisting: true,
|
||||
resource,
|
||||
headers,
|
||||
|
||||
@@ -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: () => Effect.Effect<T>) => Effect.Effect<T>
|
||||
readonly withLock: <T>(filepath: string, fn: () => Promise<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: () => Effect.Effect<T>) {
|
||||
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
|
||||
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
|
||||
return yield* Effect.promise(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, () => Effect.promise(fn)))
|
||||
return runPromise((s) => s.withLock(filepath, fn))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,8 +540,6 @@ 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",
|
||||
@@ -556,14 +554,5 @@ 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,13 +10,12 @@ 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()
|
||||
void AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
|
||||
ShareNext.init()
|
||||
Format.init()
|
||||
await LSP.init()
|
||||
File.init()
|
||||
|
||||
@@ -94,7 +94,7 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.list()))
|
||||
const result = await SessionStatus.list()
|
||||
return c.json(Object.fromEntries(result))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -123,6 +123,14 @@ export namespace SessionProcessor {
|
||||
let aborted = false
|
||||
const slog = log.with({ sessionID: input.sessionID, messageID: input.assistantMessage.id })
|
||||
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.assistantMessage.id,
|
||||
agent: input.assistantMessage.agent,
|
||||
providerID: input.model.providerID,
|
||||
modelID: input.model.id,
|
||||
})
|
||||
|
||||
const parse = (e: unknown) =>
|
||||
MessageV2.fromError(e, {
|
||||
providerID: input.model.providerID,
|
||||
@@ -531,7 +539,18 @@ export namespace SessionProcessor {
|
||||
})
|
||||
|
||||
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
|
||||
yield* slog.info("process")
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
agent: ctx.assistantMessage.agent,
|
||||
providerID: ctx.model.providerID,
|
||||
modelID: ctx.model.id,
|
||||
})
|
||||
yield* slog.info("process", {
|
||||
agent: ctx.assistantMessage.agent,
|
||||
providerID: ctx.model.providerID,
|
||||
modelID: ctx.model.id,
|
||||
})
|
||||
ctx.needsCompaction = false
|
||||
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
|
||||
|
||||
@@ -545,6 +564,17 @@ export namespace SessionProcessor {
|
||||
Stream.tap((event) => handleEvent(event)),
|
||||
Stream.takeUntil(() => ctx.needsCompaction),
|
||||
Stream.runDrain,
|
||||
Effect.withSpan(
|
||||
"SessionProcessor.stream",
|
||||
{
|
||||
attributes: {
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: ctx.assistantMessage.id,
|
||||
agent: ctx.assistantMessage.agent,
|
||||
},
|
||||
},
|
||||
{ captureStackTrace: false },
|
||||
),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() =>
|
||||
@@ -575,8 +605,19 @@ export namespace SessionProcessor {
|
||||
Effect.ensuring(cleanup()),
|
||||
)
|
||||
|
||||
if (ctx.needsCompaction) return "compact"
|
||||
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
|
||||
if (ctx.needsCompaction) {
|
||||
yield* slog.warn("compact", { finish: ctx.assistantMessage.finish, blocked: ctx.blocked })
|
||||
return "compact"
|
||||
}
|
||||
if (ctx.blocked || ctx.assistantMessage.error) {
|
||||
yield* slog.warn("stop", {
|
||||
blocked: ctx.blocked,
|
||||
finish: ctx.assistantMessage.finish,
|
||||
hasError: !!ctx.assistantMessage.error,
|
||||
})
|
||||
return "stop"
|
||||
}
|
||||
yield* slog.info("continue", { finish: ctx.assistantMessage.finish })
|
||||
return "continue"
|
||||
})
|
||||
})
|
||||
|
||||
@@ -103,12 +103,6 @@ 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)
|
||||
@@ -364,7 +358,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) =>
|
||||
run.promise(
|
||||
Effect.runPromise(
|
||||
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||
if (!["running", "pending"].includes(match.state.status)) return match
|
||||
return {
|
||||
@@ -380,14 +374,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
),
|
||||
ask: (req) =>
|
||||
permission
|
||||
.ask({
|
||||
Effect.runPromise(
|
||||
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({
|
||||
@@ -401,15 +395,26 @@ 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 run.promise(
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, options)
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
tool: item.id,
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
callID: ctx.callID,
|
||||
})
|
||||
yield* elog.info("tool.start", {
|
||||
tool: item.id,
|
||||
sessionID: ctx.sessionID,
|
||||
callID: ctx.callID,
|
||||
})
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
{ args },
|
||||
)
|
||||
const result = yield* item.execute(args, ctx)
|
||||
const result = yield* Effect.promise(() => item.execute(args, ctx))
|
||||
const output = {
|
||||
...result,
|
||||
attachments: result.attachments?.map((attachment) => ({
|
||||
@@ -427,8 +432,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (options.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||
}
|
||||
yield* elog.info("tool.done", {
|
||||
tool: item.id,
|
||||
sessionID: ctx.sessionID,
|
||||
callID: ctx.callID,
|
||||
truncated: output.metadata.truncated,
|
||||
})
|
||||
return output
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.withSpan(
|
||||
`Tool.${item.id}`,
|
||||
{
|
||||
attributes: {
|
||||
tool: item.id,
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
callID: options.toolCallId,
|
||||
},
|
||||
},
|
||||
{ captureStackTrace: false },
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -442,15 +466,22 @@ 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) =>
|
||||
run.promise(
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, opts)
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
tool: key,
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
callID: ctx.callID,
|
||||
})
|
||||
yield* elog.info("tool.start", { tool: key, sessionID: ctx.sessionID, callID: ctx.callID })
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
yield* Effect.promise(() => ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }))
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.promise(() =>
|
||||
execute(args, opts),
|
||||
)
|
||||
@@ -506,8 +537,27 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (opts.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||
}
|
||||
yield* elog.info("tool.done", {
|
||||
tool: key,
|
||||
sessionID: ctx.sessionID,
|
||||
callID: ctx.callID,
|
||||
truncated: output.metadata.truncated,
|
||||
})
|
||||
return output
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.withSpan(
|
||||
`Tool.${key}`,
|
||||
{
|
||||
attributes: {
|
||||
tool: key,
|
||||
sessionID: input.session.id,
|
||||
messageID: input.processor.message.id,
|
||||
callID: opts.toolCallId,
|
||||
},
|
||||
},
|
||||
{ captureStackTrace: false },
|
||||
),
|
||||
),
|
||||
)
|
||||
tools[key] = item
|
||||
}
|
||||
@@ -582,64 +632,63 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
let error: Error | undefined
|
||||
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))
|
||||
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))
|
||||
log.error("subtask execution failed", { error, agent: task.agent, description: task.description })
|
||||
return Effect.void
|
||||
return undefined
|
||||
}),
|
||||
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)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
).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)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const attachments = result?.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
@@ -862,7 +911,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 run.fork(sessions.updatePart(part))
|
||||
void Effect.runFork(sessions.updatePart(part))
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -1044,21 +1093,19 @@ 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"]) => {
|
||||
const controller = new AbortController()
|
||||
return read
|
||||
.execute(args, {
|
||||
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
|
||||
Effect.promise((signal: AbortSignal) =>
|
||||
read.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: controller.signal,
|
||||
abort: signal,
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true, ...extra },
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: () => Effect.void,
|
||||
})
|
||||
.pipe(Effect.onInterrupt(() => Effect.sync(() => controller.abort())))
|
||||
}
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}),
|
||||
)
|
||||
|
||||
if (part.mime === "text/plain") {
|
||||
let offset: number | undefined
|
||||
@@ -1336,6 +1383,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
|
||||
|
||||
yield* Effect.annotateCurrentSpan({
|
||||
sessionID,
|
||||
step,
|
||||
agent: lastUser.agent,
|
||||
providerID: lastUser.model.providerID,
|
||||
modelID: lastUser.model.modelID,
|
||||
})
|
||||
|
||||
const lastAssistantMsg = msgs.findLast(
|
||||
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
|
||||
)
|
||||
@@ -1357,6 +1412,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
step++
|
||||
yield* slog.info("step", {
|
||||
step,
|
||||
agent: lastUser.agent,
|
||||
providerID: lastUser.model.providerID,
|
||||
modelID: lastUser.model.modelID,
|
||||
})
|
||||
if (step === 1)
|
||||
yield* title({
|
||||
session,
|
||||
@@ -1374,6 +1435,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
if (task?.type === "compaction") {
|
||||
yield* slog.warn("compaction", { step, auto: task.auto, overflow: task.overflow })
|
||||
const result = yield* compaction.process({
|
||||
messages: msgs,
|
||||
parentID: lastUser.id,
|
||||
@@ -1478,7 +1540,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
Effect.promise(() => SystemPrompt.environment(model)),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
]).pipe(
|
||||
Effect.withSpan(
|
||||
"SessionPrompt.prepareInput",
|
||||
{
|
||||
attributes: {
|
||||
sessionID,
|
||||
step,
|
||||
agent: agent.name,
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
},
|
||||
},
|
||||
{ captureStackTrace: false },
|
||||
),
|
||||
)
|
||||
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
||||
const format = lastUser.format ?? { type: "text" as const }
|
||||
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
@@ -1664,9 +1740,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
|
||||
const promptOps: TaskPromptOps = {
|
||||
cancel: (sessionID) => run.fork(cancel(sessionID)),
|
||||
resolvePromptParts: (template) => run.promise(resolvePromptParts(template)),
|
||||
prompt: (input) => run.promise(prompt(input)),
|
||||
cancel: (sessionID) => Effect.runFork(cancel(sessionID)),
|
||||
resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)),
|
||||
prompt: (input) => Effect.runPromise(prompt(input)),
|
||||
}
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -85,4 +86,17 @@ 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,6 +4,7 @@ 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"
|
||||
@@ -347,4 +348,26 @@ 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,13 +19,12 @@ const PatchParams = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
})
|
||||
|
||||
export const ApplyPatchTool = Tool.define(
|
||||
export const ApplyPatchTool = Tool.defineEffect(
|
||||
"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) {
|
||||
@@ -179,16 +178,18 @@ export const ApplyPatchTool = Tool.define(
|
||||
|
||||
// Check permissions if needed
|
||||
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: relativePaths,
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: relativePaths.join(", "),
|
||||
diff: totalDiff,
|
||||
files,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
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" }> = []
|
||||
@@ -227,13 +228,13 @@ export const ApplyPatchTool = Tool.define(
|
||||
|
||||
if (edited) {
|
||||
yield* format.file(edited)
|
||||
yield* bus.publish(File.Event.Edited, { file: edited })
|
||||
Bus.publish(File.Event.Edited, { file: edited })
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
for (const update of updates) {
|
||||
yield* bus.publish(FileWatcher.Event.Updated, update)
|
||||
Bus.publish(FileWatcher.Event.Updated, update)
|
||||
}
|
||||
|
||||
// Notify LSP of file changes and collect diagnostics
|
||||
@@ -257,13 +258,20 @@ export const ApplyPatchTool = Tool.define(
|
||||
})
|
||||
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 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}`
|
||||
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>`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -280,7 +288,9 @@ export const ApplyPatchTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
execute: (params: z.infer<typeof PatchParams>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
async execute(params: z.infer<typeof PatchParams>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -226,21 +226,25 @@ 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* ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: globs,
|
||||
always: globs,
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (scan.patterns.size === 0) return
|
||||
yield* ctx.ask({
|
||||
permission: "bash",
|
||||
patterns: Array.from(scan.patterns),
|
||||
always: Array.from(scan.always),
|
||||
metadata: {},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
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) {
|
||||
@@ -290,7 +294,7 @@ const parser = lazy(async () => {
|
||||
})
|
||||
|
||||
// TODO: we may wanna rename this tool so it works better on other shells
|
||||
export const BashTool = Tool.define(
|
||||
export const BashTool = Tool.defineEffect(
|
||||
"bash",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
@@ -500,7 +504,7 @@ export const BashTool = Tool.define(
|
||||
},
|
||||
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.define(
|
||||
export const CodeSearchTool = Tool.defineEffect(
|
||||
"codesearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -29,15 +29,17 @@ export const CodeSearchTool = Tool.define(
|
||||
}),
|
||||
execute: (params: { query: string; tokensNum: number }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "codesearch",
|
||||
patterns: [params.query],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
query: params.query,
|
||||
tokensNum: params.tokensNum,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* McpExa.call(
|
||||
http,
|
||||
@@ -57,7 +59,7 @@ export const CodeSearchTool = Tool.define(
|
||||
title: `Code search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replaceAll("\r\n", "\n")
|
||||
@@ -41,14 +42,11 @@ const Parameters = z.object({
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
})
|
||||
|
||||
export const EditTool = Tool.define(
|
||||
export const EditTool = Tool.defineEffect(
|
||||
"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,
|
||||
@@ -71,53 +69,12 @@ export const EditTool = Tool.define(
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
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({
|
||||
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({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
@@ -126,26 +83,65 @@ export const EditTool = Tool.define(
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
yield* afs.writeWithDirs(filePath, contentNew)
|
||||
yield* format.file(filePath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filePath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Format.file(filePath)
|
||||
Bus.publish(File.Event.Edited, { file: filePath })
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
contentNew = yield* afs.readFileString(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(
|
||||
filePath,
|
||||
filePath,
|
||||
normalizeLineEndings(contentOld),
|
||||
normalizeLineEndings(contentNew),
|
||||
),
|
||||
)
|
||||
yield* filetime.read(ctx.sessionID, filePath)
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
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)
|
||||
})
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
@@ -170,8 +166,16 @@ export const EditTool = Tool.define(
|
||||
yield* lsp.touchFile(filePath, true)
|
||||
const diagnostics = yield* lsp.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
|
||||
if (block) output += `\n\nLSP errors detected in this file, please fix:\n${block}`
|
||||
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>`
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
@@ -182,7 +186,7 @@ export const EditTool = Tool.define(
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output,
|
||||
}
|
||||
}),
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -11,11 +11,7 @@ type Options = {
|
||||
kind?: Kind
|
||||
}
|
||||
|
||||
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
|
||||
ctx: Tool.Context,
|
||||
target?: string,
|
||||
options?: Options,
|
||||
) {
|
||||
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
||||
if (!target) return
|
||||
|
||||
if (options?.bypass) return
|
||||
@@ -30,7 +26,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
||||
? AppFileSystem.normalizePathPattern(path.join(dir, "*"))
|
||||
: path.join(dir, "*").replaceAll("\\", "/")
|
||||
|
||||
yield* ctx.ask({
|
||||
await ctx.ask({
|
||||
permission: "external_directory",
|
||||
patterns: [glob],
|
||||
always: [glob],
|
||||
@@ -39,8 +35,12 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
||||
parentDir: dir,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export async function assertExternalDirectory(ctx: Tool.Context, target?: string, options?: Options) {
|
||||
return Effect.runPromise(assertExternalDirectoryEffect(ctx, target, options))
|
||||
}
|
||||
|
||||
export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* (
|
||||
ctx: Tool.Context,
|
||||
target?: string,
|
||||
options?: Options,
|
||||
) {
|
||||
yield* Effect.promise(() => assertExternalDirectory(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.define(
|
||||
export const GlobTool = Tool.defineEffect(
|
||||
"glob",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
@@ -28,15 +28,17 @@ export const GlobTool = Tool.define(
|
||||
}),
|
||||
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
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)
|
||||
@@ -88,7 +90,7 @@ export const GlobTool = Tool.define(
|
||||
},
|
||||
output: output.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
export const GrepTool = Tool.define(
|
||||
export const GrepTool = Tool.defineEffect(
|
||||
"grep",
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
@@ -32,16 +32,18 @@ export const GrepTool = Tool.define(
|
||||
throw new Error("pattern is required")
|
||||
}
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "grep",
|
||||
patterns: [params.pattern],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
pattern: params.pattern,
|
||||
path: params.path,
|
||||
include: params.include,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
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)
|
||||
@@ -169,7 +171,7 @@ export const GrepTool = Tool.define(
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
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: {},
|
||||
}),
|
||||
export const InvalidTool = Tool.define("invalid", {
|
||||
description: "Do not use",
|
||||
parameters: z.object({
|
||||
tool: z.string(),
|
||||
error: z.string(),
|
||||
}),
|
||||
)
|
||||
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.define(
|
||||
export const ListTool = Tool.defineEffect(
|
||||
"list",
|
||||
Effect.gen(function* () {
|
||||
const rg = yield* Ripgrep.Service
|
||||
@@ -56,14 +56,16 @@ export const ListTool = Tool.define(
|
||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "list",
|
||||
patterns: [searchPath],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: searchPath,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
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(
|
||||
@@ -128,7 +130,7 @@ export const ListTool = Tool.define(
|
||||
},
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ const operations = [
|
||||
"outgoingCalls",
|
||||
] as const
|
||||
|
||||
export const LspTool = Tool.define(
|
||||
export const LspTool = Tool.defineEffect(
|
||||
"lsp",
|
||||
Effect.gen(function* () {
|
||||
const lsp = yield* LSP.Service
|
||||
@@ -42,7 +42,7 @@ export const LspTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
|
||||
yield* assertExternalDirectoryEffect(ctx, file)
|
||||
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
|
||||
yield* Effect.promise(() => 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.define(
|
||||
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.define(
|
||||
export const MultiEditTool = Tool.defineEffect(
|
||||
"multiedit",
|
||||
Effect.gen(function* () {
|
||||
const editInfo = yield* EditTool
|
||||
@@ -37,14 +37,16 @@ export const MultiEditTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const results = []
|
||||
for (const [, entry] of params.edits.entries()) {
|
||||
const result = yield* edit.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: entry.oldString,
|
||||
newString: entry.newString,
|
||||
replaceAll: entry.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
const result = yield* Effect.promise(() =>
|
||||
edit.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: entry.oldString,
|
||||
newString: entry.newString,
|
||||
replaceAll: entry.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
results.push(result)
|
||||
}
|
||||
@@ -55,7 +57,7 @@ export const MultiEditTool = Tool.define(
|
||||
},
|
||||
output: results.at(-1)!.output,
|
||||
}
|
||||
}),
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ function getLastModel(sessionID: SessionID) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const PlanExitTool = Tool.define(
|
||||
export const PlanExitTool = Tool.defineEffect(
|
||||
"plan_exit",
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -74,7 +74,7 @@ export const PlanExitTool = Tool.define(
|
||||
output: "User approved switching to build agent. Wait for further instructions.",
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ type Metadata = {
|
||||
answers: Question.Answer[]
|
||||
}
|
||||
|
||||
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
|
||||
export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Question.Service>(
|
||||
"question",
|
||||
Effect.gen(function* () {
|
||||
const question = yield* Question.Service
|
||||
@@ -39,7 +39,7 @@ export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Se
|
||||
answers,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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.define(
|
||||
export const ReadTool = Tool.defineEffect(
|
||||
"read",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
@@ -106,12 +106,14 @@ export const ReadTool = Tool.define(
|
||||
kind: stat?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [filepath],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "read",
|
||||
patterns: [filepath],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
|
||||
if (!stat) return yield* miss(filepath)
|
||||
|
||||
@@ -216,7 +218,9 @@ export const ReadTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -44,7 +44,6 @@ 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"
|
||||
@@ -90,12 +89,10 @@ 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* () {
|
||||
@@ -103,9 +100,7 @@ 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
|
||||
@@ -132,26 +127,23 @@ export namespace ToolRegistry {
|
||||
id,
|
||||
parameters: z.object(def.args),
|
||||
description: def.description,
|
||||
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,
|
||||
},
|
||||
}
|
||||
}),
|
||||
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,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +174,7 @@ export namespace ToolRegistry {
|
||||
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
const tool = yield* Effect.all({
|
||||
invalid: Tool.init(invalid),
|
||||
invalid: Tool.init(InvalidTool),
|
||||
bash: Tool.init(bash),
|
||||
read: Tool.init(read),
|
||||
glob: Tool.init(globtool),
|
||||
@@ -336,12 +328,10 @@ 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.define(
|
||||
export const SkillTool = Tool.defineEffect(
|
||||
"skill",
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
@@ -51,12 +51,14 @@ export const SkillTool = Tool.define(
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "skill",
|
||||
patterns: [params.name],
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
}),
|
||||
)
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
@@ -92,7 +94,7 @@ export const SkillTool = Tool.define(
|
||||
dir,
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -31,7 +31,7 @@ const parameters = z.object({
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.define(
|
||||
export const TaskTool = Tool.defineEffect(
|
||||
id,
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
@@ -41,15 +41,17 @@ export const TaskTool = Tool.define(
|
||||
const cfg = yield* config.get()
|
||||
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
yield* ctx.ask({
|
||||
permission: id,
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
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)
|
||||
@@ -176,7 +178,9 @@ export const TaskTool = Tool.define(
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie),
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx))
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ type Metadata = {
|
||||
todos: Todo.Info[]
|
||||
}
|
||||
|
||||
export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Service>(
|
||||
export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo.Service>(
|
||||
"todowrite",
|
||||
Effect.gen(function* () {
|
||||
const todo = yield* Todo.Service
|
||||
@@ -20,28 +20,29 @@ export const TodoWriteTool = Tool.define<typeof parameters, Metadata, Todo.Servi
|
||||
return {
|
||||
description: DESCRIPTION_WRITE,
|
||||
parameters,
|
||||
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.ask({
|
||||
permission: "todowrite",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
|
||||
await ctx.ask({
|
||||
permission: "todowrite",
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
yield* todo.update({
|
||||
await 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,21 +23,22 @@ 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">): Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface ExecuteResult<M extends Metadata = Metadata> {
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
|
||||
}
|
||||
|
||||
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): Effect.Effect<ExecuteResult<M>>
|
||||
execute(
|
||||
args: z.infer<Parameters>,
|
||||
ctx: Context,
|
||||
): Promise<{
|
||||
title: string
|
||||
metadata: M
|
||||
output: string
|
||||
attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
|
||||
}>
|
||||
formatValidationError?(error: z.ZodError): string
|
||||
}
|
||||
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
|
||||
@@ -73,41 +74,48 @@ export namespace Tool {
|
||||
return async () => {
|
||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
||||
const execute = toolInfo.execute
|
||||
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
|
||||
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 })
|
||||
}
|
||||
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)
|
||||
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 }),
|
||||
},
|
||||
}
|
||||
}
|
||||
return toolInfo
|
||||
}
|
||||
}
|
||||
|
||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||
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>(
|
||||
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.define(
|
||||
export const WebFetchTool = Tool.defineEffect(
|
||||
"webfetch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -33,16 +33,18 @@ export const WebFetchTool = Tool.define(
|
||||
throw new Error("URL must start with http:// or https://")
|
||||
}
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "webfetch",
|
||||
patterns: [params.url],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
url: params.url,
|
||||
format: params.format,
|
||||
timeout: params.timeout,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
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)
|
||||
|
||||
@@ -151,7 +153,7 @@ export const WebFetchTool = Tool.define(
|
||||
default:
|
||||
return { output: content, title, metadata: {} }
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ const Parameters = z.object({
|
||||
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
|
||||
})
|
||||
|
||||
export const WebSearchTool = Tool.define(
|
||||
export const WebSearchTool = Tool.defineEffect(
|
||||
"websearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
@@ -36,18 +36,20 @@ export const WebSearchTool = Tool.define(
|
||||
parameters: Parameters,
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
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,
|
||||
},
|
||||
})
|
||||
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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* McpExa.call(
|
||||
http,
|
||||
@@ -68,7 +70,7 @@ export const WebSearchTool = Tool.define(
|
||||
title: `Web search: ${params.query}`,
|
||||
metadata: {},
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,15 +15,15 @@ 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.define(
|
||||
export const WriteTool = Tool.defineEffect(
|
||||
"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 {
|
||||
@@ -44,23 +44,27 @@ export const WriteTool = Tool.define(
|
||||
if (exists) yield* filetime.assert(ctx.sessionID, filepath)
|
||||
|
||||
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
|
||||
yield* ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filepath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filepath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath,
|
||||
diff,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* fs.writeWithDirs(filepath, params.content)
|
||||
yield* format.file(filepath)
|
||||
yield* bus.publish(File.Event.Edited, { file: filepath })
|
||||
yield* bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
Bus.publish(File.Event.Edited, { file: filepath })
|
||||
yield* Effect.promise(() =>
|
||||
Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filepath,
|
||||
event: exists ? "change" : "add",
|
||||
}),
|
||||
)
|
||||
yield* filetime.read(ctx.sessionID, filepath)
|
||||
|
||||
let output = "Wrote file successfully."
|
||||
@@ -69,16 +73,20 @@ export const WriteTool = Tool.define(
|
||||
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
|
||||
let projectDiagnosticsCount = 0
|
||||
for (const [file, issues] of Object.entries(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}`
|
||||
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>`
|
||||
continue
|
||||
}
|
||||
if (projectDiagnosticsCount >= MAX_PROJECT_DIAGNOSTICS_FILES) continue
|
||||
projectDiagnosticsCount++
|
||||
output += `\n\nLSP errors detected in other files:\n${block}`
|
||||
output += `\n\nLSP errors detected in other files:\n<diagnostics file="${file}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -90,7 +98,7 @@ export const WriteTool = Tool.define(
|
||||
},
|
||||
output,
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(Effect.orDie, Effect.runPromise),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ const filetime = Layer.succeed(
|
||||
read: () => Effect.void,
|
||||
get: () => Effect.succeed(undefined),
|
||||
assert: () => Effect.void,
|
||||
withLock: (_filepath, fn) => fn(),
|
||||
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -735,12 +735,19 @@ it.live(
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const { task } = yield* registry.named()
|
||||
const original = task.execute
|
||||
task.execute = (_args, ctx) =>
|
||||
Effect.callback<never>((resume) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
return Effect.sync(() => aborted.resolve())
|
||||
})
|
||||
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: "",
|
||||
}
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original)))
|
||||
|
||||
const { prompt, chat } = yield* boot()
|
||||
@@ -1386,10 +1393,11 @@ function hangUntilAborted(tool: { execute: (...args: any[]) => any }) {
|
||||
const ready = defer<void>()
|
||||
const aborted = defer<void>()
|
||||
const original = tool.execute
|
||||
tool.execute = (_args: any, ctx: any) => {
|
||||
tool.execute = async (_args: any, ctx: any) => {
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
return Effect.callback<never>(() => {})
|
||||
await new Promise<void>(() => {})
|
||||
return { title: "", metadata: {}, output: "" }
|
||||
}
|
||||
const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original)))
|
||||
return { ready, aborted, restore }
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
@@ -95,16 +94,12 @@ describe("session.retry.delay", () => {
|
||||
parse: (err) => err as MessageV2.APIError,
|
||||
set: (info) =>
|
||||
Effect.promise(() =>
|
||||
AppRuntime.runPromise(
|
||||
SessionStatus.Service.use((svc) =>
|
||||
svc.set(sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
),
|
||||
),
|
||||
SessionStatus.set(sessionID, {
|
||||
type: "retry",
|
||||
attempt: info.attempt,
|
||||
message: info.message,
|
||||
next: info.next,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
@@ -113,7 +108,7 @@ describe("session.retry.delay", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await AppRuntime.runPromise(SessionStatus.Service.use((svc) => svc.get(sessionID)))).toMatchObject({
|
||||
expect(await SessionStatus.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) => fn(),
|
||||
withLock: (_filepath, fn) => Effect.promise(fn),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,13 +7,10 @@ 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, Bus.layer),
|
||||
)
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
|
||||
|
||||
const baseCtx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -45,23 +42,22 @@ type AskInput = {
|
||||
}
|
||||
|
||||
type ToolCtx = typeof baseCtx & {
|
||||
ask: (input: AskInput) => Effect.Effect<void>
|
||||
ask: (input: AskInput) => Promise<void>
|
||||
}
|
||||
|
||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||
const info = await runtime.runPromise(ApplyPatchTool)
|
||||
const tool = await info.init()
|
||||
return Effect.runPromise(tool.execute(params, ctx))
|
||||
return tool.execute(params, ctx)
|
||||
}
|
||||
|
||||
const makeCtx = () => {
|
||||
const calls: AskInput[] = []
|
||||
const ctx: ToolCtx = {
|
||||
...baseCtx,
|
||||
ask: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(input)
|
||||
}),
|
||||
ask: async (input) => {
|
||||
calls.push(input)
|
||||
},
|
||||
}
|
||||
|
||||
return { ctx, calls }
|
||||
|
||||
@@ -30,7 +30,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: () => Effect.void,
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
Shell.acceptable.reset()
|
||||
@@ -109,11 +109,10 @@ 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: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
if (stop) throw stop
|
||||
}),
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
requests.push(req)
|
||||
if (stop) throw stop
|
||||
},
|
||||
})
|
||||
|
||||
const mustTruncate = (result: {
|
||||
@@ -132,14 +131,12 @@ describe("tool.bash", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo test",
|
||||
description: "Echo test message",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo test",
|
||||
description: "Echo test message",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.exit).toBe(0)
|
||||
expect(result.metadata.output).toContain("test")
|
||||
@@ -156,14 +153,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
@@ -179,14 +174,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await bash.execute(
|
||||
{
|
||||
command: "echo foo && echo bar",
|
||||
description: "Echo twice",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].permission).toBe("bash")
|
||||
@@ -205,14 +198,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Write-Host foo; if ($?) { Write-Host bar }",
|
||||
description: "Check PowerShell conditional",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await 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()
|
||||
@@ -235,14 +226,12 @@ 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(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${file}`,
|
||||
description: "Read wildcard path",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -268,14 +257,12 @@ 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 Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo $(cat "${file}")`,
|
||||
description: "Read nested bash file",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await 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")
|
||||
@@ -302,14 +289,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`,
|
||||
description: "Copy Windows ini",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -331,14 +316,12 @@ 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 Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Write-Output $(Get-Content ${file})`,
|
||||
description: "Read nested PowerShell file",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await 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")
|
||||
@@ -364,14 +347,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "C:../outside.txt"',
|
||||
description: "Read drive-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -394,14 +375,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$HOME/.ssh/config"',
|
||||
description: "Read home config",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -425,14 +404,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PWD/../outside.txt"',
|
||||
description: "Read pwd-relative file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -455,14 +432,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: 'Get-Content "$PSHOME/outside.txt"',
|
||||
description: "Read pshome file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -490,14 +465,12 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`,
|
||||
description: "Read Windows ini with missing env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -522,14 +495,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Get-Content $env:WINDIR/win.ini",
|
||||
description: "Read Windows ini from env",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await 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()
|
||||
@@ -553,14 +524,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
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),
|
||||
),
|
||||
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")
|
||||
@@ -585,14 +554,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Get-Content ${env:WINDIR}/win.ini",
|
||||
description: "Read Windows ini from braced env",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -615,14 +582,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Set-Location C:/Windows",
|
||||
description: "Change location",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await 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")
|
||||
@@ -646,14 +611,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "Write-Output ('a' * 3)",
|
||||
description: "Write repeated text",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await bash.execute(
|
||||
{
|
||||
command: "Write-Output ('a' * 3)",
|
||||
description: "Write repeated text",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeDefined()
|
||||
@@ -675,14 +638,12 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd ../",
|
||||
description: "Change to parent directory",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -700,15 +661,13 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: os.tmpdir(),
|
||||
description: "Echo from temp dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -732,15 +691,13 @@ describe("tool.bash permissions", () => {
|
||||
for (const dir of forms(outerTmp.path)) {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: dir,
|
||||
description: "Echo from external dir",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
|
||||
@@ -767,15 +724,13 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/tmp",
|
||||
description: "Echo from Git Bash tmp",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo ok",
|
||||
workdir: "/tmp",
|
||||
description: "Echo from Git Bash tmp",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
).rejects.toThrow(err.message)
|
||||
expect(requests[0]).toMatchObject({
|
||||
@@ -799,14 +754,12 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const want = glob(path.join(os.tmpdir(), "*"))
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cat /tmp/opencode-does-not-exist",
|
||||
description: "Read Git Bash tmp file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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({
|
||||
@@ -836,14 +789,12 @@ describe("tool.bash permissions", () => {
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const filepath = path.join(outerTmp.path, "outside.txt")
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `cat ${filepath}`,
|
||||
description: "Read external file",
|
||||
},
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -866,14 +817,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `rm -rf ${path.join(tmp.path, "nested")}`,
|
||||
description: "Remove nested dir",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await 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()
|
||||
@@ -888,14 +837,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await bash.execute(
|
||||
{
|
||||
command: "git log --oneline -5",
|
||||
description: "Git log",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
expect(requests.length).toBe(1)
|
||||
expect(requests[0].always.length).toBeGreaterThan(0)
|
||||
@@ -911,14 +858,12 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
capture(requests),
|
||||
),
|
||||
await bash.execute(
|
||||
{
|
||||
command: "cd .",
|
||||
description: "Stay in current directory",
|
||||
},
|
||||
capture(requests),
|
||||
)
|
||||
const bashReq = requests.find((r) => r.permission === "bash")
|
||||
expect(bashReq).toBeUndefined()
|
||||
@@ -935,11 +880,9 @@ describe("tool.bash permissions", () => {
|
||||
const err = new Error("stop after permission")
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
bash.execute(
|
||||
{ command: "echo test > output.txt", description: "Redirect test output" },
|
||||
capture(requests, err),
|
||||
),
|
||||
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")
|
||||
@@ -956,7 +899,7 @@ describe("tool.bash permissions", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
await Effect.runPromise(bash.execute({ command: "ls -la", description: "List" }, capture(requests)))
|
||||
await 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 *")
|
||||
@@ -973,25 +916,24 @@ describe("tool.bash abort", () => {
|
||||
const bash = await initBash()
|
||||
const controller = new AbortController()
|
||||
const collected: string[] = []
|
||||
const res = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo before && sleep 30`,
|
||||
description: "Long running command",
|
||||
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()
|
||||
}
|
||||
},
|
||||
{
|
||||
...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)
|
||||
@@ -1004,15 +946,13 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo started && sleep 60`,
|
||||
description: "Timeout test",
|
||||
timeout: 500,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await 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")
|
||||
@@ -1025,14 +965,12 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo stdout_msg && echo stderr_msg >&2`,
|
||||
description: "Stderr test",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await 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")
|
||||
@@ -1046,14 +984,12 @@ describe("tool.bash abort", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `exit 42`,
|
||||
description: "Non-zero exit",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: `exit 42`,
|
||||
description: "Non-zero exit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.exit).toBe(42)
|
||||
},
|
||||
@@ -1066,20 +1002,18 @@ describe("tool.bash abort", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const updates: string[] = []
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: `echo first && sleep 0.1 && echo second`,
|
||||
description: "Streaming test",
|
||||
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)
|
||||
},
|
||||
{
|
||||
...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")
|
||||
@@ -1096,14 +1030,12 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 500
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
@@ -1118,14 +1050,12 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const byteCount = Truncate.MAX_BYTES + 10000
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: fill("bytes", byteCount),
|
||||
description: "Generate bytes exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: fill("bytes", byteCount),
|
||||
description: "Generate bytes exceeding limit",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
mustTruncate(result)
|
||||
expect(result.output).toContain("truncated")
|
||||
@@ -1139,14 +1069,12 @@ describe("tool.bash truncation", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: "echo hello",
|
||||
description: "Echo hello",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect((result.metadata as { truncated?: boolean }).truncated).toBe(false)
|
||||
expect(result.output).toContain("hello")
|
||||
@@ -1160,14 +1088,12 @@ describe("tool.bash truncation", () => {
|
||||
fn: async () => {
|
||||
const bash = await initBash()
|
||||
const lineCount = Truncate.MAX_LINES + 100
|
||||
const result = await Effect.runPromise(
|
||||
bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines for file check",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await bash.execute(
|
||||
{
|
||||
command: fill("lines", lineCount),
|
||||
description: "Generate lines for file check",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
mustTruncate(result)
|
||||
|
||||
|
||||
@@ -7,10 +7,6 @@ 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 = {
|
||||
@@ -21,7 +17,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: () => Effect.void,
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -33,9 +29,7 @@ async function touch(file: string, time: number) {
|
||||
await fs.utimes(file, date, date)
|
||||
}
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
||||
)
|
||||
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer))
|
||||
|
||||
afterAll(async () => {
|
||||
await runtime.dispose()
|
||||
@@ -49,12 +43,6 @@ 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 () => {
|
||||
@@ -65,15 +53,13 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.diff).toContain("new content")
|
||||
@@ -92,15 +78,13 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -116,21 +100,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 = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(events).toContain("updated")
|
||||
@@ -149,18 +133,16 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Edit applied successfully")
|
||||
@@ -178,19 +160,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("not found")
|
||||
},
|
||||
@@ -207,15 +187,13 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
@@ -230,19 +208,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
@@ -259,15 +235,13 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("You must read file")
|
||||
},
|
||||
@@ -284,7 +258,7 @@ describe("tool.edit", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
@@ -293,15 +267,13 @@ describe("tool.edit", () => {
|
||||
// Try to edit with the new content
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
@@ -316,19 +288,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
newString: "qux",
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
newString: "qux",
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -345,23 +315,22 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubUpdated = await subscribeBus(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(events).toContain("updated")
|
||||
@@ -380,18 +349,16 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -408,18 +375,16 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
@@ -438,15 +403,13 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
@@ -461,19 +424,17 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, dirpath)
|
||||
await FileTime.read(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await resolve()
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("directory")
|
||||
},
|
||||
@@ -488,18 +449,16 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
const result = await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.filediff).toBeDefined()
|
||||
@@ -561,17 +520,15 @@ describe("tool.edit", () => {
|
||||
fn: async () => {
|
||||
const edit = await resolve()
|
||||
const filePath = path.join(tmp.path, "test.txt")
|
||||
await readFileTime(ctx.sessionID, filePath)
|
||||
await Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath,
|
||||
oldString: input.oldString,
|
||||
newString: input.newString,
|
||||
replaceAll: input.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
await FileTime.read(ctx.sessionID, filePath)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath,
|
||||
oldString: input.oldString,
|
||||
newString: input.newString,
|
||||
replaceAll: input.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
return await Bun.file(filePath).text()
|
||||
},
|
||||
@@ -704,34 +661,30 @@ describe("tool.edit", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await resolve()
|
||||
|
||||
// Two concurrent edits
|
||||
const promise1 = Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "1",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const promise1 = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "1",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
await readFileTime(ctx.sessionID, filepath)
|
||||
await FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const promise2 = Effect.runPromise(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "2",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const promise2 = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "2",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
// Both should complete without error (though one might fail due to content mismatch)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
@@ -22,21 +21,15 @@ 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, ctx } = makeCtx()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp",
|
||||
@@ -49,7 +42,13 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("no-ops for paths inside Instance.directory", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
@@ -62,7 +61,13 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("asks with a single canonical glob", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside/file.txt"
|
||||
@@ -82,7 +87,13 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("uses target directory when kind=directory", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
const directory = "/tmp/project"
|
||||
const target = "/tmp/outside"
|
||||
@@ -102,7 +113,13 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("skips prompting when bypass=true", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await Instance.provide({
|
||||
directory: "/tmp/project",
|
||||
@@ -116,7 +133,13 @@ describe("tool.assertExternalDirectory", () => {
|
||||
|
||||
if (process.platform === "win32") {
|
||||
test("normalizes Windows path variants to one glob", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
await using outerTmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -146,7 +169,13 @@ describe("tool.assertExternalDirectory", () => {
|
||||
})
|
||||
|
||||
test("uses drive root glob for root files", async () => {
|
||||
const { requests, ctx } = makeCtx()
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
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: () => Effect.void,
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
@@ -32,15 +32,13 @@ describe("tool.grep", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await Effect.runPromise(
|
||||
grep.execute(
|
||||
{
|
||||
pattern: "export",
|
||||
path: path.join(projectRoot, "src/tool"),
|
||||
include: "*.ts",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "export",
|
||||
path: path.join(projectRoot, "src/tool"),
|
||||
include: "*.ts",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBeGreaterThan(0)
|
||||
expect(result.output).toContain("Found")
|
||||
@@ -58,14 +56,12 @@ describe("tool.grep", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await Effect.runPromise(
|
||||
grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "xyznonexistentpatternxyz123",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.matches).toBe(0)
|
||||
expect(result.output).toBe("No files found")
|
||||
@@ -85,14 +81,12 @@ describe("tool.grep", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await initGrep()
|
||||
const result = await Effect.runPromise(
|
||||
grep.execute(
|
||||
{
|
||||
pattern: "line",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
const result = await 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: () => Effect.void,
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
@@ -49,7 +49,7 @@ describe("tool.question", () => {
|
||||
},
|
||||
]
|
||||
|
||||
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const fiber = yield* Effect.promise(() => 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* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
|
||||
const fiber = yield* Effect.promise(() => 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: () => Effect.void,
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
@@ -54,7 +54,7 @@ const run = Effect.fn("ReadToolTest.run")(function* (
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* tool.execute(args, next)
|
||||
return yield* Effect.promise(() => tool.execute(args, next))
|
||||
})
|
||||
|
||||
const exec = Effect.fn("ReadToolTest.exec")(function* (
|
||||
@@ -95,10 +95,9 @@ const asks = () => {
|
||||
items,
|
||||
next: {
|
||||
...ctx,
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
items.push(req)
|
||||
}),
|
||||
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
|
||||
items.push(req)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -227,18 +226,17 @@ describe("tool.read env file permissions", () => {
|
||||
let asked = false
|
||||
const next = {
|
||||
...ctx,
|
||||
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 })
|
||||
}
|
||||
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
|
||||
}
|
||||
}),
|
||||
if (rule.action === "deny") {
|
||||
throw new Permission.DeniedError({ ruleset: info.permission })
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
yield* run({ filePath: path.join(dir, filename) }, next)
|
||||
|
||||
@@ -156,13 +156,12 @@ Use this skill.
|
||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
const ctx: Tool.Context = {
|
||||
...baseCtx,
|
||||
ask: (req) =>
|
||||
Effect.sync(() => {
|
||||
requests.push(req)
|
||||
}),
|
||||
ask: async (req) => {
|
||||
requests.push(req)
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runtime.runPromise(tool.execute({ name: "tool-skill" }, ctx))
|
||||
const result = await 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,23 +194,25 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
||||
|
||||
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 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 kids = yield* sessions.children(chat.id)
|
||||
@@ -233,25 +235,26 @@ describe("tool.task", () => {
|
||||
const promptOps = stubOps()
|
||||
|
||||
const exec = (extra?: Record<string, any>) =>
|
||||
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(() => {
|
||||
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) => {
|
||||
calls.push(input)
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
yield* exec()
|
||||
@@ -281,23 +284,25 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
||||
|
||||
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 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 kids = yield* sessions.children(chat.id)
|
||||
@@ -321,22 +326,24 @@ describe("tool.task", () => {
|
||||
let seen: SessionPrompt.PromptInput | undefined
|
||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||
|
||||
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 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 child = yield* sessions.get(result.metadata.sessionId)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Tool } from "../../src/tool/tool"
|
||||
|
||||
@@ -9,9 +8,9 @@ function makeTool(id: string, executeFn?: () => void) {
|
||||
return {
|
||||
description: "test tool",
|
||||
parameters: params,
|
||||
execute() {
|
||||
async execute() {
|
||||
executeFn?.()
|
||||
return Effect.succeed({ title: "test", output: "ok", metadata: {} })
|
||||
return { title: "test", output: "ok", metadata: {} }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -21,34 +20,29 @@ describe("Tool.define", () => {
|
||||
const original = makeTool("test")
|
||||
const originalExecute = original.execute
|
||||
|
||||
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
||||
const tool = Tool.define("test-tool", original)
|
||||
|
||||
await info.init()
|
||||
await info.init()
|
||||
await info.init()
|
||||
await tool.init()
|
||||
await tool.init()
|
||||
await tool.init()
|
||||
|
||||
expect(original.execute).toBe(originalExecute)
|
||||
})
|
||||
|
||||
test("function-defined tool returns fresh objects and is unaffected", async () => {
|
||||
const info = await Effect.runPromise(
|
||||
Tool.define(
|
||||
"test-fn-tool",
|
||||
Effect.succeed(() => Promise.resolve(makeTool("test"))),
|
||||
),
|
||||
)
|
||||
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
|
||||
|
||||
const first = await info.init()
|
||||
const second = await info.init()
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
test("object-defined tool returns distinct objects per init() call", async () => {
|
||||
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
||||
const tool = Tool.define("test-copy", makeTool("test"))
|
||||
|
||||
const first = await info.init()
|
||||
const second = await info.init()
|
||||
const first = await tool.init()
|
||||
const second = await tool.init()
|
||||
|
||||
expect(first).not.toBe(second)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: () => Effect.void,
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
|
||||
@@ -42,8 +42,9 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await Effect.runPromise(
|
||||
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
|
||||
const result = await webfetch.execute(
|
||||
{ url: new URL("/image.png", url).toString(), format: "markdown" },
|
||||
ctx,
|
||||
)
|
||||
expect(result.output).toBe("Image fetched successfully")
|
||||
expect(result.attachments).toBeDefined()
|
||||
@@ -73,9 +74,7 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await Effect.runPromise(
|
||||
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
|
||||
)
|
||||
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
|
||||
expect(result.output).toContain("<svg")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
@@ -96,9 +95,7 @@ describe("tool.webfetch", () => {
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const webfetch = await initTool()
|
||||
const result = await Effect.runPromise(
|
||||
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
|
||||
)
|
||||
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
|
||||
expect(result.output).toBe("hello from webfetch")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
@@ -23,7 +22,7 @@ const ctx = {
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => {},
|
||||
ask: () => Effect.void,
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -35,9 +34,15 @@ const it = testEffect(
|
||||
LSP.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
FileTime.defaultLayer,
|
||||
Bus.layer,
|
||||
Format.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Layer.succeed(
|
||||
Format.Service,
|
||||
Format.Service.of({
|
||||
init: () => Effect.void,
|
||||
status: () => Effect.succeed([]),
|
||||
file: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -51,7 +56,7 @@ const run = Effect.fn("WriteToolTest.run")(function* (
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* tool.execute(args, next)
|
||||
return yield* Effect.promise(() => tool.execute(args, next))
|
||||
})
|
||||
|
||||
const markRead = Effect.fn("WriteToolTest.markRead")(function* (sessionID: string, filepath: string) {
|
||||
|
||||
@@ -316,6 +316,29 @@ 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)
|
||||
@@ -387,29 +410,6 @@ 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,6 +446,29 @@ 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: {
|
||||
@@ -500,29 +523,6 @@ 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,6 +7986,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"]
|
||||
},
|
||||
"QuestionOption": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8136,50 +8201,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"]
|
||||
},
|
||||
"SessionStatus": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8286,6 +8307,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"]
|
||||
},
|
||||
"Event.worktree.ready": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8440,71 +8505,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"]
|
||||
},
|
||||
"OutputFormatText": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -9937,6 +9937,15 @@
|
||||
{
|
||||
"$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"
|
||||
},
|
||||
@@ -9946,9 +9955,6 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.question.rejected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.status"
|
||||
},
|
||||
@@ -9958,6 +9964,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.session.compacted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.todo.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.worktree.ready"
|
||||
},
|
||||
@@ -9976,15 +9985,6 @@
|
||||
{
|
||||
"$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