Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 53136cf57f tweak: improve motel observability for session flows 2026-04-10 22:11:38 -04:00
52 changed files with 1459 additions and 1375 deletions
+1 -3
View File
@@ -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.
+6 -9
View File
@@ -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 })
}
})
}
},
}
}
+2 -3
View File
@@ -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)
+1 -1
View File
@@ -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),
+2 -1
View File
@@ -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,
+4 -4
View File
@@ -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))
}
}
-11
View File
@@ -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>`
}
}
}
+1 -2
View File
@@ -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))
},
)
+44 -3
View File
@@ -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"
})
})
+164 -88
View File
@@ -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({
+14
View File
@@ -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))
}
}
+23
View File
@@ -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))
}
}
+29 -19
View File
@@ -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))
},
}
}),
)
+18 -14
View File
@@ -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),
}
}
}),
+13 -11
View File
@@ -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),
}
}),
)
+77 -73
View File
@@ -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))
})
+13 -11
View File
@@ -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 -12
View File
@@ -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),
}
}),
)
+13 -16
View File
@@ -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: {},
}
},
})
+12 -10
View File
@@ -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),
}
}),
)
+3 -3
View File
@@ -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),
}
}),
)
+12 -10
View File
@@ -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),
}
}),
)
+2 -2
View File
@@ -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),
}
}),
)
+2 -2
View File
@@ -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),
}
}),
)
+12 -8
View File
@@ -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))
},
}
}),
)
+18 -28
View File
@@ -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),
),
)
+10 -8
View File
@@ -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),
}
}
}),
+15 -11
View File
@@ -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))
},
}
}),
)
+19 -18
View File
@@ -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>
}),
)
+47 -39
View File
@@ -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 } {
+14 -12
View File
@@ -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),
}
}),
)
+16 -14
View File
@@ -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),
}
}),
)
+32 -24
View File
@@ -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 }
+7 -12
View File
@@ -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 }
+244 -318
View File
@@ -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)
+155 -202
View File
@@ -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
+20 -26
View File
@@ -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)
},
+3 -3
View File
@@ -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"]] })
+15 -17
View File
@@ -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)
+4 -5
View File
@@ -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")
+75 -68
View File
@@ -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)
+12 -18
View File
@@ -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)
})
+6 -9
View File
@@ -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()
},
+10 -5
View File
@@ -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) {
+50 -50
View File
@@ -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
View File
@@ -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"
},