Compare commits

...

6 Commits

Author SHA1 Message Date
Kit Langton
5d6fe01465 convert skill tool to Tool.defineEffect (#21936) 2026-04-10 19:49:53 -04:00
Kit Langton
cf27a73397 feat: add AppRuntime for unified service composition (#21953) 2026-04-10 19:46:52 -04:00
opencode-agent[bot]
f2c492a8e6 chore: generate 2026-04-10 23:43:20 +00:00
Kit Langton
0556774097 refactor(tool): convert apply_patch to Tool.defineEffect (#21938) 2026-04-10 19:42:14 -04:00
Kit Langton
d9d5a0615e refactor: break SessionPrompt/TaskTool cycle via ctx injection (#21948) 2026-04-10 19:36:13 -04:00
Kit Langton
d72ddd71fa refactor(tool): convert grep tool to Tool.defineEffect (#21937) 2026-04-10 19:20:00 -04:00
18 changed files with 685 additions and 534 deletions

View File

@@ -169,6 +169,8 @@ export namespace Bus {
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,

View File

@@ -0,0 +1,100 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Observability } from "./oltp"
import { AppFileSystem } from "@/filesystem"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account"
import { Config } from "@/config/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { Todo } from "@/session/todo"
import { Session } from "@/session"
import { SessionStatus } from "@/session/status"
import { SessionRunState } from "@/session/run-state"
import { SessionProcessor } from "@/session/processor"
import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll(
Observability.layer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,
Provider.defaultLayer,
ProviderAuth.defaultLayer,
Agent.defaultLayer,
Skill.defaultLayer,
Discovery.defaultLayer,
Question.defaultLayer,
Permission.defaultLayer,
Todo.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
LSP.defaultLayer,
MCP.defaultLayer,
McpAuth.defaultLayer,
Command.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
)
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })

View File

@@ -141,7 +141,7 @@ export namespace McpAuth {
}),
)
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -226,7 +226,7 @@ export namespace Vcs {
}),
)
const defaultLayer = layer.pipe(
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),

View File

@@ -359,7 +359,7 @@ export namespace Pty {
}),
)
const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -46,7 +46,7 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
import { TaskTool, type TaskPromptOps } from "@/tool/task"
import { SessionRunState } from "./run-state"
// @ts-ignore
@@ -356,7 +356,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
abort: options.abortSignal!,
messageID: input.processor.message.id,
callID: options.toolCallId,
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck },
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
agent: input.agent.name,
messages: input.messages,
metadata: (val) =>
@@ -586,7 +586,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID,
abort: signal,
callID: part.callID,
extra: { bypassAgentCheck: true },
extra: { bypassAgentCheck: true, promptOps },
messages: msgs,
metadata(val: { title?: string; metadata?: Record<string, any> }) {
return Effect.runPromise(
@@ -1655,6 +1655,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return result
})
const promptOps: TaskPromptOps = {
cancel: (sessionID) => Effect.runFork(cancel(sessionID)),
resolvePromptParts: (template) => Effect.runPromise(resolvePromptParts(template)),
prompt: (input) => Effect.runPromise(prompt(input)),
}
return Service.of({
cancel,
prompt,
@@ -1666,7 +1672,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}),
)
const defaultLayer = Layer.suspend(() =>
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),

View File

@@ -1,16 +1,16 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Effect } from "effect"
import { Tool } from "./tool"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
import { Patch } from "../patch"
import { createTwoFilesPatch, diffLines } from "diff"
import { assertExternalDirectory } from "./external-directory"
import { assertExternalDirectoryEffect } from "./external-directory"
import { trimDiff } from "./edit"
import { LSP } from "../lsp"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
import { Format } from "../format"
@@ -19,261 +19,278 @@ const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export const ApplyPatchTool = Tool.define("apply_patch", {
description: DESCRIPTION,
parameters: PatchParams,
async execute(params, ctx) {
if (!params.patchText) {
throw new Error("patchText is required")
}
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
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
}
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
throw new Error("patch rejected: empty patch")
const run = Effect.fn("ApplyPatchTool.execute")(function* (params: z.infer<typeof PatchParams>, ctx: Tool.Context) {
if (!params.patchText) {
return yield* Effect.fail(new Error("patchText is required"))
}
throw new Error("apply_patch verification failed: no hunks found")
}
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
// Parse the patch to get hunks
let hunks: Patch.Hunk[]
try {
const parseResult = Patch.parsePatch(params.patchText)
hunks = parseResult.hunks
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
await assertExternalDirectory(ctx, filePath)
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
if (hunks.length === 0) {
const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim()
if (normalized === "*** Begin Patch\n*** End Patch") {
return yield* Effect.fail(new Error("patch rejected: empty patch"))
}
return yield* Effect.fail(new Error("apply_patch verification failed: no hunks found"))
}
case "update": {
// Check if file exists for update
const stats = await fs.stat(filePath).catch(() => null)
if (!stats || stats.isDirectory()) {
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
// Validate file paths and check permissions
const fileChanges: Array<{
filePath: string
oldContent: string
newContent: string
type: "add" | "update" | "delete" | "move"
movePath?: string
diff: string
additions: number
deletions: number
}> = []
let totalDiff = ""
for (const hunk of hunks) {
const filePath = path.resolve(Instance.directory, hunk.path)
yield* assertExternalDirectoryEffect(ctx, filePath)
switch (hunk.type) {
case "add": {
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
fileChanges.push({
filePath,
oldContent,
newContent,
type: "add",
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
case "update": {
// Check if file exists for update
const stats = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stats || stats.type === "Directory") {
return yield* Effect.fail(
new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`),
)
}
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
throw new Error(`apply_patch verification failed: ${error}`)
const oldContent = yield* afs.readFileString(filePath)
let newContent = oldContent
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
yield* assertExternalDirectoryEffect(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
case "delete": {
const contentToDelete = yield* afs
.readFileString(filePath)
.pipe(Effect.catch((error) => Effect.fail(new Error(`apply_patch verification failed: ${error}`))))
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
let additions = 0
let deletions = 0
for (const change of diffLines(oldContent, newContent)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
}
const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined
await assertExternalDirectory(ctx, movePath)
fileChanges.push({
filePath,
oldContent,
newContent,
type: hunk.move_path ? "move" : "update",
movePath,
diff,
additions,
deletions,
})
totalDiff += diff + "\n"
break
}
case "delete": {
const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => {
throw new Error(`apply_patch verification failed: ${error}`)
})
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length
fileChanges.push({
filePath,
oldContent: contentToDelete,
newContent: "",
type: "delete",
diff: deleteDiff,
additions: 0,
deletions,
})
totalDiff += deleteDiff + "\n"
break
}
}
}
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
// Build per-file metadata for UI rendering (used for both permission and result)
const files = fileChanges.map((change) => ({
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,
}))
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
await ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
})
// Check permissions if needed
const relativePaths = fileChanges.map((c) => path.relative(Instance.worktree, c.filePath).replaceAll("\\", "/"))
yield* Effect.promise(() =>
ctx.ask({
permission: "edit",
patterns: relativePaths,
always: ["*"],
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
},
}),
)
// Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
// Apply the changes
const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = []
for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.filePath), { recursive: true })
await fs.writeFile(change.filePath, change.newContent, "utf-8")
updates.push({ file: change.filePath, event: "add" })
break
case "update":
await fs.writeFile(change.filePath, change.newContent, "utf-8")
updates.push({ file: change.filePath, event: "change" })
break
case "move":
if (change.movePath) {
for (const change of fileChanges) {
const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath)
switch (change.type) {
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
await fs.mkdir(path.dirname(change.movePath), { recursive: true })
await fs.writeFile(change.movePath, change.newContent, "utf-8")
await fs.unlink(change.filePath)
yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "add" })
break
case "update":
yield* afs.writeWithDirs(change.filePath, change.newContent)
updates.push({ file: change.filePath, event: "change" })
break
case "move":
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
yield* afs.writeWithDirs(change.movePath!, change.newContent)
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
}
break
case "delete":
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
}
break
break
}
case "delete":
await fs.unlink(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
break
if (edited) {
yield* format.file(edited)
Bus.publish(File.Event.Edited, { file: edited })
}
}
if (edited) {
await Format.file(edited)
Bus.publish(File.Event.Edited, { file: edited })
// Publish file change events
for (const update of updates) {
Bus.publish(FileWatcher.Event.Updated, update)
}
}
// Publish file change events
for (const update of updates) {
await Bus.publish(FileWatcher.Event.Updated, update)
}
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
await LSP.touchFile(target, true)
}
const diagnostics = await LSP.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
// Notify LSP of file changes and collect diagnostics
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
yield* lsp.touchFile(target, true)
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
const diagnostics = yield* lsp.diagnostics()
// Generate output summary
const summaryLines = fileChanges.map((change) => {
if (change.type === "add") {
return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
if (change.type === "delete") {
return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}`
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
// Report LSP errors for changed files
const MAX_DIAGNOSTICS_PER_FILE = 20
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const normalized = AppFileSystem.normalizePath(target)
const issues = diagnostics[normalized] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}, please fix:\n<diagnostics file="${target}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
}
}
return {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
},
output,
}
const target = change.movePath ?? change.filePath
return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}`
})
let output = `Success. Updated the following files:\n${summaryLines.join("\n")}`
// Report LSP errors for changed files
const MAX_DIAGNOSTICS_PER_FILE = 20
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
const normalized = Filesystem.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 {
title: output,
metadata: {
diff: totalDiff,
files,
diagnostics,
description: DESCRIPTION,
parameters: PatchParams,
async execute(params: z.infer<typeof PatchParams>, ctx) {
return Effect.runPromise(run(params, ctx).pipe(Effect.orDie))
},
output,
}
},
})
}),
)

View File

@@ -1,156 +1,177 @@
import z from "zod"
import { text } from "node:stream/consumers"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { Process } from "../util/process"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
import path from "path"
import { assertExternalDirectory } from "./external-directory"
import { assertExternalDirectoryEffect } from "./external-directory"
const MAX_LINE_LENGTH = 2000
export const GrepTool = Tool.define("grep", {
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
async execute(params, ctx) {
if (!params.pattern) {
throw new Error("pattern is required")
}
await 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)
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
const proc = Process.spawn([rgPath, ...args], {
stdout: "pipe",
stderr: "pipe",
abort: ctx.abort,
})
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}
const output = await text(proc.stdout)
const errorOutput = await text(proc.stderr)
const exitCode = await proc.exited
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
for (const match of finalMatches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
outputLines.push("")
}
currentFile = match.path
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
if (truncated) {
outputLines.push("")
outputLines.push(
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
)
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
export const GrepTool = Tool.defineEffect(
"grep",
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
return {
title: params.pattern,
metadata: {
matches: totalMatches,
truncated,
},
output: outputLines.join("\n"),
description: DESCRIPTION,
parameters: z.object({
pattern: z.string().describe("The regex pattern to search for in file contents"),
path: z.string().optional().describe("The directory to search in. Defaults to the current working directory."),
include: z.string().optional().describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
}),
execute: (params: { pattern: string; path?: string; include?: string }, ctx: Tool.Context) =>
Effect.gen(function* () {
if (!params.pattern) {
throw new Error("pattern is required")
}
yield* Effect.promise(() =>
ctx.ask({
permission: "grep",
patterns: [params.pattern],
always: ["*"],
metadata: {
pattern: params.pattern,
path: params.path,
include: params.include,
},
}),
)
let searchPath = params.path ?? Instance.directory
searchPath = path.isAbsolute(searchPath) ? searchPath : path.resolve(Instance.directory, searchPath)
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
const rgPath = yield* Effect.promise(() => Ripgrep.filepath())
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
args.push(searchPath)
const result = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(
ChildProcess.make(rgPath, args, {
stdin: "ignore",
}),
)
const [output, errorOutput] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const exitCode = yield* handle.exitCode
return { output, errorOutput, exitCode }
}),
)
const { output, errorOutput, exitCode } = result
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)
// With --no-messages, we suppress error output but still get exit code 2 for broken symlinks etc.
// Only fail if exit code is 2 AND no output was produced
if (exitCode === 1 || (exitCode === 2 && !output.trim())) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
if (exitCode !== 0 && exitCode !== 2) {
throw new Error(`ripgrep failed: ${errorOutput}`)
}
const hasErrors = exitCode === 2
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = output.trim().split(/\r?\n/)
const matches = []
for (const line of lines) {
if (!line) continue
const [filePath, lineNumStr, ...lineTextParts] = line.split("|")
if (!filePath || !lineNumStr || lineTextParts.length === 0) continue
const lineNum = parseInt(lineNumStr, 10)
const lineText = lineTextParts.join("|")
const stats = Filesystem.stat(filePath)
if (!stats) continue
matches.push({
path: filePath,
modTime: stats.mtime.getTime(),
lineNum,
lineText,
})
}
matches.sort((a, b) => b.modTime - a.modTime)
const limit = 100
const truncated = matches.length > limit
const finalMatches = truncated ? matches.slice(0, limit) : matches
if (finalMatches.length === 0) {
return {
title: params.pattern,
metadata: { matches: 0, truncated: false },
output: "No files found",
}
}
const totalMatches = matches.length
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
let currentFile = ""
for (const match of finalMatches) {
if (currentFile !== match.path) {
if (currentFile !== "") {
outputLines.push("")
}
currentFile = match.path
outputLines.push(`${match.path}:`)
}
const truncatedLineText =
match.lineText.length > MAX_LINE_LENGTH
? match.lineText.substring(0, MAX_LINE_LENGTH) + "..."
: match.lineText
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
}
if (truncated) {
outputLines.push("")
outputLines.push(
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
)
}
if (hasErrors) {
outputLines.push("")
outputLines.push("(Some paths were inaccessible and skipped)")
}
return {
title: params.pattern,
metadata: {
matches: totalMatches,
truncated,
},
output: outputLines.join("\n"),
}
}).pipe(Effect.orDie, Effect.runPromise),
}
},
})
}),
)

View File

@@ -34,6 +34,7 @@ import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env"
@@ -91,6 +92,7 @@ export namespace ToolRegistry {
| HttpClient.HttpClient
| ChildProcessSpawner
| Ripgrep.Service
| Format.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -112,6 +114,9 @@ export namespace ToolRegistry {
const globtool = yield* GlobTool
const writetool = yield* WriteTool
const edit = yield* EditTool
const greptool = yield* GrepTool
const patchtool = yield* ApplyPatchTool
const skilltool = yield* SkillTool
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -173,7 +178,7 @@ export namespace ToolRegistry {
bash: Tool.init(bash),
read: Tool.init(read),
glob: Tool.init(globtool),
grep: Tool.init(GrepTool),
grep: Tool.init(greptool),
edit: Tool.init(edit),
write: Tool.init(writetool),
task: Tool.init(task),
@@ -181,8 +186,8 @@ export namespace ToolRegistry {
todo: Tool.init(todo),
search: Tool.init(websearch),
code: Tool.init(codesearch),
skill: Tool.init(SkillTool),
patch: Tool.init(ApplyPatchTool),
skill: Tool.init(skilltool),
patch: Tool.init(patchtool),
question: Tool.init(question),
lsp: Tool.init(lsptool),
plan: Tool.init(plan),
@@ -324,6 +329,7 @@ export namespace ToolRegistry {
Layer.provide(Instruction.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(Format.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
),

View File

@@ -1,99 +1,101 @@
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.available()
export const SkillTool = Tool.defineEffect(
"skill",
Effect.gen(function* () {
const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return async () => {
const list = await Effect.runPromise(skill.available())
return {
description,
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
await ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(skill.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = await iife(async () => {
const arr = []
for await (const file of Ripgrep.files({
cwd: dir,
follow: false,
hidden: true,
signal: ctx.abort,
})) {
if (file.includes("SKILL.md")) {
continue
}
arr.push(path.resolve(dir, file))
if (arr.length >= limit) {
break
}
}
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
const description =
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return {
title: `Loaded skill: ${skill.name}`,
output: [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
"",
skill.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: skill.name,
dir,
},
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* Effect.promise(() =>
ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
}),
)
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie, Effect.runPromise),
}
},
}
})
}
}),
)

View File

@@ -5,11 +5,17 @@ import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import type { SessionPrompt } from "../session/prompt"
import { Config } from "../config/config"
import { Effect } from "effect"
import { Log } from "@/util/log"
export interface TaskPromptOps {
cancel(sessionID: SessionID): void
resolvePromptParts(template: string): Promise<SessionPrompt.PromptInput["parts"]>
prompt(input: SessionPrompt.PromptInput): Promise<MessageV2.WithParts>
}
const id = "task"
const parameters = z.object({
@@ -113,10 +119,13 @@ export const TaskTool = Tool.defineEffect(
},
})
const ops = ctx.extra?.promptOps as TaskPromptOps
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(nextSession.id)
ops.cancel(nextSession.id)
}
return yield* Effect.acquireUseRelease(
@@ -125,9 +134,9 @@ export const TaskTool = Tool.defineEffect(
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
const parts = yield* Effect.promise(() => ops.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
SessionPrompt.prompt({
ops.prompt({
messageID,
sessionID: nextSession.id,
model: {

View File

@@ -590,7 +590,7 @@ export namespace Worktree {
}),
)
const defaultLayer = layer.pipe(
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Project.defaultLayer),

View File

@@ -38,6 +38,7 @@ import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
@@ -174,6 +175,7 @@ function makeHttp() {
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),

View File

@@ -54,6 +54,7 @@ import { Truncate } from "../../src/tool/truncate"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Ripgrep } from "../../src/file/ripgrep"
import { Format } from "../../src/format"
Log.init({ print: false })
@@ -138,6 +139,7 @@ function makeHttp() {
Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(Format.defaultLayer),
Layer.provideMerge(todo),
Layer.provideMerge(question),
Layer.provideMerge(deps),

View File

@@ -1,11 +1,17 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import * as fs from "fs/promises"
import { Effect, ManagedRuntime, Layer } from "effect"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance"
import { LSP } from "../../src/lsp"
import { AppFileSystem } from "../../src/filesystem"
import { Format } from "../../src/format"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
const runtime = ManagedRuntime.make(Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer))
const baseCtx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
@@ -40,7 +46,8 @@ type ToolCtx = typeof baseCtx & {
}
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const tool = await ApplyPatchTool.init()
const info = await runtime.runPromise(ApplyPatchTool)
const tool = await info.init()
return tool.execute(params, ctx)
}

View File

@@ -1,9 +1,17 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Effect, Layer, ManagedRuntime } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
}
const ctx = {
sessionID: SessionID.make("ses_test"),
@@ -23,7 +31,7 @@ describe("tool.grep", () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const grep = await GrepTool.init()
const grep = await initGrep()
const result = await grep.execute(
{
pattern: "export",
@@ -47,7 +55,7 @@ describe("tool.grep", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const grep = await initGrep()
const result = await grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
@@ -72,7 +80,7 @@ describe("tool.grep", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const grep = await GrepTool.init()
const grep = await initGrep()
const result = await grep.execute(
{
pattern: "line",

View File

@@ -1,4 +1,6 @@
import { Effect } from "effect"
import { Effect, Layer, ManagedRuntime } from "effect"
import { Skill } from "../../src/skill"
import { Ripgrep } from "../../src/file/ripgrep"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
@@ -148,7 +150,9 @@ Use this skill.
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
const info = await runtime.runPromise(SkillTool)
const tool = await info.init()
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,

View File

@@ -6,10 +6,10 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool } from "../../src/tool/task"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -62,6 +62,17 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
return { chat, assistant }
})
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
return {
cancel() {},
resolvePromptParts: async (template) => [{ type: "text", text: template }],
prompt: async (input) => {
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done")
},
}
}
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
const id = MessageID.ascending()
return {
@@ -180,21 +191,8 @@ describe("tool.task", () => {
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "resumed")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const result = yield* Effect.promise(() =>
def.execute(
@@ -209,6 +207,7 @@ describe("tool.task", () => {
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata() {},
ask: async () => {},
@@ -232,20 +231,10 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
const calls: unknown[] = []
const promptOps = stubOps()
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => reply(input, "done")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const exec = (extra?: { bypassAgentCheck?: boolean }) =>
const exec = (extra?: Record<string, any>) =>
Effect.promise(() =>
def.execute(
{
@@ -258,7 +247,7 @@ describe("tool.task", () => {
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra,
extra: { promptOps, ...extra },
messages: [],
metadata() {},
ask: async (input) => {
@@ -292,21 +281,8 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "created")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const result = yield* Effect.promise(() =>
def.execute(
@@ -321,6 +297,7 @@ describe("tool.task", () => {
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata() {},
ask: async () => {},
@@ -346,21 +323,8 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "done")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const result = yield* Effect.promise(() =>
def.execute(
@@ -374,6 +338,7 @@ describe("tool.task", () => {
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata() {},
ask: async () => {},