chore(opencode): remove scout agent (#30435)
This commit is contained in:
parent
18ba80f3ac
commit
a639fe7a08
30 changed files with 26 additions and 925 deletions
|
|
@ -115,8 +115,6 @@ export const Plugin = PluginV2.define({
|
|||
{ action: "question", resource: "*", effect: "deny" },
|
||||
{ action: "plan_enter", resource: "*", effect: "deny" },
|
||||
{ action: "plan_exit", resource: "*", effect: "deny" },
|
||||
{ action: "repo_clone", resource: "*", effect: "deny" },
|
||||
{ action: "repo_overview", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ mutable `Flag` or late `process.env` reads.
|
|||
Tests should vary behavior with explicit layer variants:
|
||||
|
||||
```ts
|
||||
const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalScout: true }))))
|
||||
const it = testEffect(MyService.defaultLayer.pipe(Layer.provide(RuntimeFlags.layer({ experimentalReferences: true }))))
|
||||
```
|
||||
|
||||
Do not mutate `process.env` or `Flag` after services/layers are built.
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ Recently completed:
|
|||
- [x] Built-in websearch provider selection uses the same runtime flags as
|
||||
tool visibility.
|
||||
- [x] Removed global default-plugin disabling from test preload.
|
||||
- [x] `RF-1` Scout reads routed through runtime flags (#27318).
|
||||
- [x] `RF-1` Reference reads routed through runtime flags (#27318).
|
||||
- [x] `RF-2` Plan-mode prompt read routed through runtime flags (#27320).
|
||||
- [x] `RF-3` Event-system reads routed through runtime flags (#27323).
|
||||
- [x] `RF-4` Workspaces reads routed through runtime flags for session
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ export function toToolKind(toolName: string): ToolKind {
|
|||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "repo_clone":
|
||||
case "repo_overview":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
|
|
@ -78,8 +76,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
|
|||
|
||||
case "grep":
|
||||
case "glob":
|
||||
case "repo_clone":
|
||||
case "repo_overview":
|
||||
case "context":
|
||||
case "context7_resolve_library_id":
|
||||
case "context7_get_library_docs":
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { ProviderTransform } from "@/provider/transform"
|
|||
import PROMPT_GENERATE from "./generate.txt"
|
||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||
import PROMPT_SCOUT from "./prompt/scout.txt"
|
||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||
import PROMPT_TITLE from "./prompt/title.txt"
|
||||
import { Permission } from "@/permission"
|
||||
|
|
@ -22,7 +21,6 @@ import { Plugin } from "@/plugin"
|
|||
import { Skill } from "../skill"
|
||||
import { Effect, Context, Layer, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import * as Option from "effect/Option"
|
||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||
import { type DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
|
@ -89,7 +87,6 @@ export const layer = Layer.effect(
|
|||
const plugin = yield* Plugin.Service
|
||||
const skill = yield* Skill.Service
|
||||
const provider = yield* Provider.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
|
|
@ -115,8 +112,6 @@ export const layer = Layer.effect(
|
|||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
plan_exit: "deny",
|
||||
repo_clone: "deny",
|
||||
repo_overview: "deny",
|
||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||
read: {
|
||||
"*": "allow",
|
||||
|
|
@ -204,36 +199,6 @@ export const layer = Layer.effect(
|
|||
mode: "subagent",
|
||||
native: true,
|
||||
},
|
||||
...(flags.experimentalScout
|
||||
? {
|
||||
scout: {
|
||||
name: "scout",
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
"*": "deny",
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
read: "allow",
|
||||
repo_clone: "allow",
|
||||
repo_overview: "allow",
|
||||
external_directory: {
|
||||
...readonlyExternalDirectory,
|
||||
[path.join(Global.Path.repos, "*")]: "allow",
|
||||
},
|
||||
}),
|
||||
user,
|
||||
),
|
||||
description: `Docs and dependency-source specialist. Use this when you need to inspect external documentation, clone dependency repositories into the managed cache, and research library implementation details without modifying the user's workspace.`,
|
||||
prompt: PROMPT_SCOUT,
|
||||
options: {},
|
||||
mode: "subagent" as const,
|
||||
native: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
compaction: {
|
||||
name: "compaction",
|
||||
mode: "primary",
|
||||
|
|
@ -462,7 +427,6 @@ export const defaultLayer = layer.pipe(
|
|||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Agent from "./agent"
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
You are `scout`, a read-only research agent for external libraries, dependency source, and documentation.
|
||||
|
||||
Your purpose is to investigate code outside the local workspace and return evidence-backed findings without modifying the user's workspace.
|
||||
|
||||
Use this agent when asked to:
|
||||
- inspect dependency repositories or library source
|
||||
- compare local code against upstream implementations
|
||||
- research public GitHub repositories the environment can clone
|
||||
- explain how a library or framework works by reading its source and docs
|
||||
- investigate third-party APIs, workflows, or behavior outside the current workspace
|
||||
|
||||
Working style:
|
||||
1. When the task involves a GitHub repository or dependency source, use `repo_clone` first.
|
||||
2. After cloning, use `Glob`, `Grep`, and `Read` to inspect the cloned repository.
|
||||
3. Use `WebFetch` for official documentation pages when source alone is not enough.
|
||||
4. Prefer direct code and documentation evidence over assumptions.
|
||||
5. If multiple external repositories are relevant, inspect each one before drawing conclusions.
|
||||
|
||||
Research standards:
|
||||
- cite exact absolute file paths and line references whenever possible
|
||||
- separate what is verified from what is inferred
|
||||
- if the answer depends on branch state, note that you are reading the repository's current default clone state unless the caller specifies otherwise
|
||||
- if a repository cannot be cloned or accessed, say so explicitly and continue with whatever evidence is still available
|
||||
- call out uncertainty clearly instead of smoothing over gaps
|
||||
|
||||
Output expectations:
|
||||
- start with the direct answer
|
||||
- then explain the evidence repository by repository or source by source
|
||||
- include file references when relevant
|
||||
- keep the explanation organized and easy to scan
|
||||
|
||||
Constraints:
|
||||
- do not modify files or run tools that change the user's workspace
|
||||
- return absolute file paths for cloned-repo findings in your final response
|
||||
|
||||
Complete the user's research request efficiently and report your findings clearly.
|
||||
|
|
@ -325,7 +325,7 @@ export function Autocomplete(props: {
|
|||
...(problem
|
||||
? [`Problem: ${problem}`]
|
||||
: [
|
||||
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
|
||||
"Inspect the configured reference with Read, Glob, and Grep when useful.",
|
||||
]),
|
||||
].join("\n")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,7 +209,6 @@ export const Info = Schema.Struct({
|
|||
// subagent
|
||||
general: Schema.optional(ConfigAgent.Info),
|
||||
explore: Schema.optional(ConfigAgent.Info),
|
||||
scout: Schema.optional(ConfigAgent.Info),
|
||||
// specialized
|
||||
title: Schema.optional(ConfigAgent.Info),
|
||||
summary: Schema.optional(ConfigAgent.Info),
|
||||
|
|
|
|||
|
|
@ -27,8 +27,6 @@ const InputObject = Schema.StructWithRest(
|
|||
question: Schema.optional(Action),
|
||||
webfetch: Schema.optional(Action),
|
||||
websearch: Schema.optional(Action),
|
||||
repo_clone: Schema.optional(Rule),
|
||||
repo_overview: Schema.optional(Rule),
|
||||
lsp: Schema.optional(Rule),
|
||||
doom_loop: Schema.optional(Action),
|
||||
skill: Schema.optional(Rule),
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ const Git = Schema.Struct({
|
|||
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}),
|
||||
branch: Schema.optional(Schema.String).annotate({
|
||||
description: "Branch or ref Scout should clone and inspect",
|
||||
description: "Branch or ref to clone and inspect",
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||
}).pipe(Config.map((flags) => flags.enabled || flags.legacy)),
|
||||
enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
|
||||
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||
experimentalScout: enabledByExperimental("OPENCODE_EXPERIMENTAL_SCOUT"),
|
||||
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
|
||||
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
|
||||
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ const materializers = Effect.fn("Reference.materializers")(function* (
|
|||
})
|
||||
|
||||
function materializeAll(input: { flags: RuntimeFlags.Info; materializers: Materializer[] }) {
|
||||
if (!input.flags.experimentalScout) return Effect.void
|
||||
if (!input.flags.experimentalReferences) return Effect.void
|
||||
return Effect.forEach(
|
||||
input.materializers,
|
||||
Effect.fnUntraced(function* (item) {
|
||||
|
|
@ -205,7 +205,7 @@ export const layer = Layer.effect(
|
|||
|
||||
return Service.of({
|
||||
init: Effect.fn("Reference.init")(function* () {
|
||||
if (!flags.experimentalScout) return
|
||||
if (!flags.experimentalReferences) return
|
||||
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
}),
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
|
|
@ -215,13 +215,13 @@ export const layer = Layer.effect(
|
|||
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
|
||||
}),
|
||||
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
|
||||
if (!flags.experimentalScout) return
|
||||
if (!flags.experimentalReferences) return
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
|
||||
}),
|
||||
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||
if (!flags.experimentalScout) return false
|
||||
if (!flags.experimentalReferences) return false
|
||||
const full = normalizedTarget(target)
|
||||
if (!full) return false
|
||||
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function referenceTextPart(input: {
|
|||
...(metadata.problem
|
||||
? [`Problem: ${metadata.problem}`]
|
||||
: [
|
||||
"For targeted context, inspect the reference path directly with Read, Glob, and Grep. For broader research, call the task tool with subagent scout and include this reference path.",
|
||||
"Inspect the configured reference with Read, Glob, and Grep when useful.",
|
||||
]),
|
||||
].join("\n"),
|
||||
metadata: { reference: metadata },
|
||||
|
|
|
|||
|
|
@ -227,8 +227,7 @@ file, `disable: true` in frontmatter.
|
|||
|
||||
### Built-in agents
|
||||
|
||||
opencode ships with `build`, `plan`, `general`, `explore`, plus optionally
|
||||
`scout` (gated on `OPENCODE_EXPERIMENTAL_SCOUT`). Hidden internal agents:
|
||||
opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents:
|
||||
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
||||
same key in `agent: { <name>: { ... } }`.
|
||||
|
||||
|
|
@ -335,8 +334,8 @@ rules last.
|
|||
everything" and is rarely what the user wants.
|
||||
|
||||
Known permission keys: `read, edit, glob, grep, list, bash, task,
|
||||
external_directory, todowrite, question, webfetch, websearch, repo_clone,
|
||||
repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite,
|
||||
external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop,
|
||||
skill`. Some of these (`todowrite,
|
||||
question, webfetch, websearch, doom_loop`) only accept a flat
|
||||
action, not a per-pattern object.
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,6 @@ import { Plugin } from "../plugin"
|
|||
import { Provider } from "@/provider/provider"
|
||||
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { RepoCloneTool } from "./repo_clone"
|
||||
import { RepoOverviewTool } from "./repo_overview"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { LspTool } from "./lsp"
|
||||
import * as Truncate from "./truncate"
|
||||
|
|
@ -48,7 +45,6 @@ import { Instruction } from "../session/instruction"
|
|||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Git } from "@/git"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
import { Reference } from "@/reference/reference"
|
||||
|
|
@ -97,8 +93,6 @@ export const layer: Layer.Layer<
|
|||
| Session.Service
|
||||
| BackgroundJob.Service
|
||||
| Provider.Service
|
||||
| Git.Service
|
||||
| RepositoryCache.Service
|
||||
| Reference.Service
|
||||
| LSP.Service
|
||||
| Instruction.Service
|
||||
|
|
@ -130,8 +124,6 @@ export const layer: Layer.Layer<
|
|||
const plan = yield* PlanExitTool
|
||||
const webfetch = yield* WebFetchTool
|
||||
const websearch = yield* WebSearchTool
|
||||
const repoClone = yield* RepoCloneTool
|
||||
const repoOverview = yield* RepoOverviewTool
|
||||
const shell = yield* ShellTool
|
||||
const globtool = yield* GlobTool
|
||||
const writetool = yield* WriteTool
|
||||
|
|
@ -241,8 +233,6 @@ export const layer: Layer.Layer<
|
|||
fetch: Tool.init(webfetch),
|
||||
todo: Tool.init(todo),
|
||||
search: Tool.init(websearch),
|
||||
repo_clone: Tool.init(repoClone),
|
||||
repo_overview: Tool.init(repoOverview),
|
||||
skill: Tool.init(skilltool),
|
||||
patch: Tool.init(patchtool),
|
||||
question: Tool.init(question),
|
||||
|
|
@ -265,7 +255,6 @@ export const layer: Layer.Layer<
|
|||
tool.fetch,
|
||||
tool.todo,
|
||||
tool.search,
|
||||
...(flags.experimentalScout ? [tool.repo_clone, tool.repo_overview] : []),
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||
|
|
@ -388,7 +377,6 @@ export const defaultLayer = Layer.suspend(() =>
|
|||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { Effect, Schema } from "effect"
|
||||
import DESCRIPTION from "./repo_clone.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { repositoryCachePath } from "@/util/repository"
|
||||
import { RepositoryCache } from "@/reference/repository-cache"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
repository: Schema.String.annotate({
|
||||
description: "Repository to clone, as a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}),
|
||||
refresh: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "When true, fetches the latest remote state into the managed cache",
|
||||
}),
|
||||
branch: Schema.optional(Schema.String).annotate({
|
||||
description: "Branch or ref to clone and inspect",
|
||||
}),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
repository: string
|
||||
host: string
|
||||
remote: string
|
||||
localPath: string
|
||||
status: "cached" | "cloned" | "refreshed"
|
||||
head?: string
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export const RepoCloneTool = Tool.define<typeof Parameters, Metadata, RepositoryCache.Service>(
|
||||
"repo_clone",
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* RepositoryCache.Service
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const reference = yield* RepositoryCache.parseRemoteReference(params.repository)
|
||||
if (params.branch) yield* RepositoryCache.validateBranch(params.branch)
|
||||
|
||||
const repository = reference.label
|
||||
const remote = reference.remote
|
||||
const localPath = repositoryCachePath(reference)
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "repo_clone",
|
||||
patterns: [repository],
|
||||
always: [repository],
|
||||
metadata: {
|
||||
repository,
|
||||
remote,
|
||||
path: localPath,
|
||||
refresh: Boolean(params.refresh),
|
||||
branch: params.branch,
|
||||
},
|
||||
})
|
||||
|
||||
const result = yield* cache.ensure({ reference, refresh: params.refresh, branch: params.branch })
|
||||
return {
|
||||
title: repository,
|
||||
metadata: result,
|
||||
output: [
|
||||
`Repository ready: ${repository}`,
|
||||
`Status: ${result.status}`,
|
||||
`Local path: ${localPath}`,
|
||||
...(result.branch ? [`Branch: ${result.branch}`] : []),
|
||||
...(result.head ? [`HEAD: ${result.head}`] : []),
|
||||
].join("\n"),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchIf(RepositoryCache.isError, (error) => Effect.fail(new Error(error.message))),
|
||||
Effect.orDie,
|
||||
),
|
||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
- Clone or refresh a repository into OpenCode's managed cache under the data directory
|
||||
- Accepts git URLs, forge host/path references, or GitHub owner/repo shorthand
|
||||
- Returns the cached absolute local path so other tools can explore the cloned source
|
||||
- Use this before Read, Glob, or Grep when the code you need lives outside the current workspace
|
||||
- This tool is intended for dependency and documentation research workflows, not for modifying the user's workspace
|
||||
|
|
@ -1,279 +0,0 @@
|
|||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Git } from "@/git"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./repo_overview.txt"
|
||||
import * as Tool from "./tool"
|
||||
import { parseRepositoryReference, repositoryCachePath } from "@/util/repository"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
repository: Schema.optional(Schema.String).annotate({
|
||||
description: "Cached repository to inspect, as a git URL, host/path reference, or GitHub owner/repo shorthand",
|
||||
}),
|
||||
path: Schema.optional(Schema.String).annotate({
|
||||
description: "Directory path to inspect instead of a cached repository",
|
||||
}),
|
||||
depth: Schema.optional(Schema.Number).annotate({
|
||||
description: "Maximum structure depth to include. Defaults to 3.",
|
||||
}),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
path: string
|
||||
repository?: string
|
||||
branch?: string
|
||||
head?: string
|
||||
package_manager?: string
|
||||
ecosystems: string[]
|
||||
dependency_files: string[]
|
||||
entrypoints: string[]
|
||||
depth: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
const IGNORED_DIRS = new Set([
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
"target",
|
||||
"vendor",
|
||||
])
|
||||
const STRUCTURE_LIMIT = 200
|
||||
const DEPENDENCY_FILES = [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"bun.lock",
|
||||
"bun.lockb",
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"requirements.txt",
|
||||
"pyproject.toml",
|
||||
"go.mod",
|
||||
"Cargo.toml",
|
||||
"Gemfile",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
"pom.xml",
|
||||
"composer.json",
|
||||
]
|
||||
|
||||
function packageManager(files: Set<string>) {
|
||||
if (files.has("bun.lock") || files.has("bun.lockb")) return "bun"
|
||||
if (files.has("pnpm-lock.yaml")) return "pnpm"
|
||||
if (files.has("yarn.lock")) return "yarn"
|
||||
if (files.has("package-lock.json")) return "npm"
|
||||
}
|
||||
|
||||
function ecosystems(files: Set<string>) {
|
||||
return [
|
||||
...(files.has("package.json") ? ["Node.js"] : []),
|
||||
...(files.has("pyproject.toml") || files.has("requirements.txt") ? ["Python"] : []),
|
||||
...(files.has("go.mod") ? ["Go"] : []),
|
||||
...(files.has("Cargo.toml") ? ["Rust"] : []),
|
||||
...(files.has("Gemfile") ? ["Ruby"] : []),
|
||||
...(files.has("build.gradle") || files.has("build.gradle.kts") || files.has("pom.xml") ? ["Java/Kotlin"] : []),
|
||||
...(files.has("composer.json") ? ["PHP"] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function commonEntrypoints(files: Set<string>) {
|
||||
return [
|
||||
"index.ts",
|
||||
"index.tsx",
|
||||
"index.js",
|
||||
"index.mjs",
|
||||
"main.ts",
|
||||
"main.js",
|
||||
"src/index.ts",
|
||||
"src/index.tsx",
|
||||
"src/index.js",
|
||||
"src/main.ts",
|
||||
"src/main.js",
|
||||
].filter((file) => files.has(file))
|
||||
}
|
||||
|
||||
export const RepoOverviewTool = Tool.define<typeof Parameters, Metadata, AppFileSystem.Service | Git.Service>(
|
||||
"repo_overview",
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const resolveTarget = Effect.fn("RepoOverviewTool.resolveTarget")(function* (
|
||||
params: Schema.Schema.Type<typeof Parameters>,
|
||||
) {
|
||||
if (params.path) {
|
||||
const full = path.isAbsolute(params.path)
|
||||
? params.path
|
||||
: path.resolve(yield* InstanceState.directory, params.path)
|
||||
return { path: full, repository: params.repository }
|
||||
}
|
||||
|
||||
if (!params.repository) throw new Error("Either repository or path is required")
|
||||
|
||||
const parsed = parseRepositoryReference(params.repository)
|
||||
if (!parsed) throw new Error("Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand")
|
||||
|
||||
const repository = parsed.label
|
||||
return {
|
||||
repository,
|
||||
path: repositoryCachePath(parsed),
|
||||
}
|
||||
})
|
||||
|
||||
const structure = Effect.fn("RepoOverviewTool.structure")(function* (root: string, depth: number) {
|
||||
let truncated = false
|
||||
const lines: string[] = []
|
||||
|
||||
const visit: (dir: string, level: number) => Effect.Effect<void> = Effect.fnUntraced(function* (
|
||||
dir: string,
|
||||
level: number,
|
||||
) {
|
||||
if (level >= depth || lines.length >= STRUCTURE_LIMIT) {
|
||||
truncated = truncated || lines.length >= STRUCTURE_LIMIT
|
||||
return
|
||||
}
|
||||
|
||||
const entries = yield* fs.readDirectoryEntries(dir).pipe(Effect.orElseSucceed(() => []))
|
||||
const sorted = yield* Effect.forEach(
|
||||
entries,
|
||||
Effect.fnUntraced(function* (entry) {
|
||||
if (IGNORED_DIRS.has(entry.name)) return undefined
|
||||
const full = path.join(dir, entry.name)
|
||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) return undefined
|
||||
return { name: entry.name, full, directory: info.type === "Directory" }
|
||||
}),
|
||||
{ concurrency: 16 },
|
||||
).pipe(
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item): item is { name: string; full: string; directory: boolean } => Boolean(item))
|
||||
.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name)),
|
||||
),
|
||||
)
|
||||
|
||||
for (const entry of sorted) {
|
||||
if (lines.length >= STRUCTURE_LIMIT) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
|
||||
lines.push(`${" ".repeat(level)}${entry.name}${entry.directory ? "/" : ""}`)
|
||||
if (entry.directory) yield* visit(entry.full, level + 1)
|
||||
}
|
||||
})
|
||||
|
||||
yield* visit(root, 0)
|
||||
return { lines, truncated }
|
||||
})
|
||||
|
||||
return {
|
||||
description: DESCRIPTION,
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* resolveTarget(params)
|
||||
const depth =
|
||||
!params.depth || !Number.isInteger(params.depth) || params.depth < 1 || params.depth > 6 ? 3 : params.depth
|
||||
|
||||
yield* assertExternalDirectoryEffect(ctx, target.path, { kind: "directory" })
|
||||
yield* ctx.ask({
|
||||
permission: "repo_overview",
|
||||
patterns: [target.repository ?? target.path],
|
||||
always: [target.repository ?? target.path],
|
||||
metadata: {
|
||||
repository: target.repository,
|
||||
path: target.path,
|
||||
depth,
|
||||
},
|
||||
})
|
||||
|
||||
const info = yield* fs.stat(target.path).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) {
|
||||
if (target.repository)
|
||||
throw new Error(`Repository is not cloned: ${target.repository}. Use repo_clone first.`)
|
||||
throw new Error(`Directory not found: ${target.path}`)
|
||||
}
|
||||
if (info.type !== "Directory") throw new Error(`Path is not a directory: ${target.path}`)
|
||||
|
||||
const entries = yield* fs.readDirectoryEntries(target.path).pipe(Effect.orElseSucceed(() => []))
|
||||
const topLevel = new Set(entries.map((entry) => entry.name))
|
||||
const dependencyFiles = DEPENDENCY_FILES.filter((file) => topLevel.has(file))
|
||||
const packageJson = topLevel.has("package.json")
|
||||
? ((yield* fs
|
||||
.readJson(path.join(target.path, "package.json"))
|
||||
.pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
const entrypoints = [
|
||||
...(typeof packageJson.main === "string" ? [`main: ${packageJson.main}`] : []),
|
||||
...(typeof packageJson.module === "string" ? [`module: ${packageJson.module}`] : []),
|
||||
...(typeof packageJson.types === "string" ? [`types: ${packageJson.types}`] : []),
|
||||
...(typeof packageJson.bin === "string" ? [`bin: ${packageJson.bin}`] : []),
|
||||
...(packageJson.bin && typeof packageJson.bin === "object" && !Array.isArray(packageJson.bin)
|
||||
? Object.keys(packageJson.bin as Record<string, unknown>).map((name) => `bin: ${name}`)
|
||||
: []),
|
||||
...(packageJson.exports && typeof packageJson.exports === "object" && !Array.isArray(packageJson.exports)
|
||||
? Object.keys(packageJson.exports as Record<string, unknown>)
|
||||
.slice(0, 10)
|
||||
.map((name) => `exports: ${name}`)
|
||||
: []),
|
||||
]
|
||||
|
||||
const common = commonEntrypoints(
|
||||
new Set([
|
||||
...topLevel,
|
||||
...entries
|
||||
.filter((entry) => entry.name === "src")
|
||||
.flatMap(() => ["src/index.ts", "src/index.tsx", "src/index.js", "src/main.ts", "src/main.js"]),
|
||||
]),
|
||||
)
|
||||
const structureResult = yield* structure(target.path, depth)
|
||||
const branch = yield* git.branch(target.path)
|
||||
const head = yield* git.run(["rev-parse", "HEAD"], { cwd: target.path })
|
||||
const headText = head.exitCode === 0 ? head.text().trim() : undefined
|
||||
|
||||
const metadata: Metadata = {
|
||||
path: target.path,
|
||||
repository: target.repository,
|
||||
branch,
|
||||
head: headText,
|
||||
package_manager: packageManager(topLevel),
|
||||
ecosystems: ecosystems(topLevel),
|
||||
dependency_files: dependencyFiles,
|
||||
entrypoints: [...entrypoints, ...common.map((file) => `file: ${file}`)],
|
||||
depth,
|
||||
truncated: structureResult.truncated,
|
||||
}
|
||||
|
||||
return {
|
||||
title: target.repository ?? path.basename(target.path),
|
||||
metadata,
|
||||
output: [
|
||||
`Path: ${target.path}`,
|
||||
...(target.repository ? [`Repository: ${target.repository}`] : []),
|
||||
...(branch ? [`Branch: ${branch}`] : []),
|
||||
...(headText ? [`HEAD: ${headText}`] : []),
|
||||
...(metadata.ecosystems.length ? [`Ecosystems: ${metadata.ecosystems.join(", ")}`] : []),
|
||||
...(metadata.package_manager ? [`Package manager: ${metadata.package_manager}`] : []),
|
||||
...(metadata.dependency_files.length
|
||||
? [`Dependency files: ${metadata.dependency_files.join(", ")}`]
|
||||
: []),
|
||||
...(metadata.entrypoints.length
|
||||
? ["Likely entrypoints:", ...metadata.entrypoints.map((entry) => `- ${entry}`)]
|
||||
: []),
|
||||
"Top-level structure:",
|
||||
...structureResult.lines,
|
||||
...(structureResult.truncated ? ["(Structure truncated)"] : []),
|
||||
].join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
} satisfies Tool.DefWithoutID<typeof Parameters, Metadata>
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
- Summarize the structure and likely entrypoints of a cloned repository or local directory
|
||||
- Accepts either a cached repository reference or a directory path
|
||||
- Reports detected ecosystems, dependency files, package manager, likely entrypoints, and a compact structure tree
|
||||
- Use this after repo_clone to orient quickly before deeper Read, Glob, or Grep investigation
|
||||
|
|
@ -19,8 +19,6 @@ describe("acp tool conversion", () => {
|
|||
expect(toToolKind("write")).toBe("edit")
|
||||
expect(toToolKind("grep")).toBe("search")
|
||||
expect(toToolKind("glob")).toBe("search")
|
||||
expect(toToolKind("repo_clone")).toBe("search")
|
||||
expect(toToolKind("repo_overview")).toBe("search")
|
||||
expect(toToolKind("context7_resolve_library_id")).toBe("search")
|
||||
expect(toToolKind("context7_get_library_docs")).toBe("search")
|
||||
expect(toToolKind("read")).toBe("read")
|
||||
|
|
@ -33,8 +31,6 @@ describe("acp tool conversion", () => {
|
|||
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
|
||||
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
||||
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
|
||||
expect(toLocations("repo_clone", { path: "/repo" })).toEqual([{ path: "/repo" }])
|
||||
expect(toLocations("repo_overview", { path: "/repo" })).toEqual([{ path: "/repo" }])
|
||||
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
|
||||
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
|
||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ const agentLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||
)
|
||||
|
||||
const it = testEffect(agentLayer())
|
||||
const scout = testEffect(agentLayer({ experimentalScout: true }))
|
||||
|
||||
// Helper to evaluate permission for a tool with wildcard pattern
|
||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionLegacy.Action | undefined {
|
||||
|
|
@ -56,7 +55,6 @@ it.instance("returns default native agents when no config", () =>
|
|||
expect(names).toContain("plan")
|
||||
expect(names).toContain("general")
|
||||
expect(names).toContain("explore")
|
||||
expect(names).not.toContain("scout")
|
||||
expect(names).toContain("compaction")
|
||||
expect(names).toContain("title")
|
||||
expect(names).toContain("summary")
|
||||
|
|
@ -71,8 +69,6 @@ it.instance("build agent has correct default properties", () =>
|
|||
expect(build?.native).toBe(true)
|
||||
expect(evalPerm(build, "edit")).toBe("allow")
|
||||
expect(evalPerm(build, "bash")).toBe("allow")
|
||||
expect(evalPerm(build, "repo_clone")).toBe("deny")
|
||||
expect(evalPerm(build, "repo_overview")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -110,31 +106,12 @@ it.instance("explore agent asks for external directories and allows whitelisted
|
|||
}),
|
||||
)
|
||||
|
||||
scout.instance("scout agent allows repo cloning and repo cache reads", () =>
|
||||
Effect.gen(function* () {
|
||||
const scout = yield* load((svc) => svc.get("scout"))
|
||||
expect(scout).toBeDefined()
|
||||
expect(scout?.mode).toBe("subagent")
|
||||
expect(evalPerm(scout, "repo_clone")).toBe("allow")
|
||||
expect(evalPerm(scout, "repo_overview")).toBe("allow")
|
||||
expect(evalPerm(scout, "edit")).toBe("deny")
|
||||
expect(
|
||||
Permission.evaluate(
|
||||
"external_directory",
|
||||
path.join(Global.Path.repos, "github.com", "owner", "repo", "README.md"),
|
||||
scout!.permission,
|
||||
).action,
|
||||
).toBe("allow")
|
||||
}),
|
||||
)
|
||||
|
||||
scout.instance(
|
||||
it.instance(
|
||||
"reference config does not create subagents",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* load((svc) => svc.list())
|
||||
const names = agents.map((agent) => agent.name)
|
||||
expect(names).toContain("scout")
|
||||
expect(names).not.toContain("effect")
|
||||
expect(names).not.toContain("effectFull")
|
||||
expect(names).not.toContain("localdocs")
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ describe("RuntimeFlags", () => {
|
|||
expect(flags.enableParallel).toBe(true)
|
||||
expect(flags.enableExperimentalModels).toBe(true)
|
||||
expect(flags.enableQuestionTool).toBe(true)
|
||||
expect(flags.experimentalScout).toBe(true)
|
||||
expect(flags.experimentalReferences).toBe(true)
|
||||
expect(flags.experimentalBackgroundSubagents).toBe(true)
|
||||
expect(flags.experimentalLspTy).toBe(false)
|
||||
expect(flags.experimentalLspTool).toBe(true)
|
||||
|
|
|
|||
|
|
@ -27,12 +27,12 @@ const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||
const it = testEffect(
|
||||
Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
||||
)
|
||||
const scout = testEffect(
|
||||
const references = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
referenceLayer({ experimentalScout: true }),
|
||||
referenceLayer({ experimentalReferences: true }),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ describe("reference", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
scout.live("materializes configured git references during init", () =>
|
||||
references.live("materializes configured git references during init", () =>
|
||||
provideTmpdirInstance(
|
||||
(_dir) =>
|
||||
Effect.gen(function* () {
|
||||
|
|
@ -243,7 +243,7 @@ describe("reference", () => {
|
|||
),
|
||||
)
|
||||
|
||||
scout.live("refreshes configured git references on new instance init", () =>
|
||||
references.live("refreshes configured git references on new instance init", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
|
||||
|
|
|
|||
|
|
@ -2009,7 +2009,7 @@ noLLMServer.instance(
|
|||
|
||||
expect(reference?.metadata?.reference).toMatchObject({ name: "docs", kind: "local", path: docs })
|
||||
expect(synthetic.some((part) => part.text.includes(`Reference root: ${docs}`))).toBe(true)
|
||||
expect(synthetic.some((part) => part.text.includes("subagent scout"))).toBe(true)
|
||||
expect(synthetic.some((part) => part.text.includes("Inspect the configured reference"))).toBe(true)
|
||||
|
||||
yield* sessions.remove(session.id)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||
)
|
||||
|
||||
const it = testEffect(toolLayer())
|
||||
const scout = testEffect(toolLayer({ experimentalScout: true }))
|
||||
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
|
|
@ -143,7 +143,7 @@ describe("tool.glob", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
scout.instance(
|
||||
references.instance(
|
||||
"does not ask for external_directory permission inside configured git references",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||
)
|
||||
|
||||
const it = testEffect(toolLayer())
|
||||
const scout = testEffect(toolLayer({ experimentalScout: true }))
|
||||
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
||||
const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
|
||||
|
||||
const ctx = {
|
||||
|
|
@ -215,7 +215,7 @@ describe("tool.grep", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
scout.instance(
|
||||
references.instance(
|
||||
"does not ask for external_directory permission inside configured git references",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer))
|
||||
const scout = testEffect(Layer.mergeAll(readLayer({ experimentalScout: true }), testInstanceStoreLayer))
|
||||
const references = testEffect(Layer.mergeAll(readLayer({ experimentalReferences: true }), testInstanceStoreLayer))
|
||||
|
||||
const init = Effect.fn("ReadToolTest.init")(function* () {
|
||||
const info = yield* ReadTool
|
||||
|
|
@ -264,7 +264,7 @@ describe("tool.read external_directory permission", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
scout.live("does not ask for external_directory permission when reading configured references", () =>
|
||||
references.live("does not ask for external_directory permission when reading configured references", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
|
||||
|
|
|
|||
|
|
@ -98,9 +98,6 @@ const brokenPluginLayer = Layer.succeed(
|
|||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||
const scout = testEffect(
|
||||
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
|
||||
)
|
||||
const withBrokenPlugin = testEffect(
|
||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||
)
|
||||
|
|
@ -110,26 +107,6 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe("tool.registry", () => {
|
||||
it.instance("hides repo research tools unless experimental", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).not.toContain("repo_clone")
|
||||
expect(ids).not.toContain("repo_overview")
|
||||
}),
|
||||
)
|
||||
|
||||
scout.instance("shows repo research tools when experimental scout is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
expect(ids).toContain("repo_clone")
|
||||
expect(ids).toContain("repo_overview")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("does not expose task_status", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Git } from "../../src/git"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { RepoCloneTool } from "../../src/tool/repo_clone"
|
||||
import { RepositoryCache } from "../../src/reference/repository-cache"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
callID: "",
|
||||
agent: "scout",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const init = Effect.fn("RepoCloneToolTest.init")(function* () {
|
||||
const info = yield* RepoCloneTool
|
||||
return yield* info.init()
|
||||
})
|
||||
|
||||
const git = Effect.fn("RepoCloneToolTest.git")(function* (cwd: string, args: string[]) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const proc = Bun.spawn(["git", ...args], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
])
|
||||
if (code !== 0) {
|
||||
throw new Error(stderr.trim() || stdout.trim() || `git ${args.join(" ")} failed`)
|
||||
}
|
||||
return stdout.trim()
|
||||
})
|
||||
})
|
||||
|
||||
const githubBase = <A, E, R>(url: string, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||
process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = url
|
||||
return previous
|
||||
}),
|
||||
() => self,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous) process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL = previous
|
||||
else delete process.env.OPENCODE_REPO_CLONE_GITHUB_BASE_URL
|
||||
}),
|
||||
)
|
||||
|
||||
describe("tool.repo_clone", () => {
|
||||
it.instance("clones a repo into the managed cache and reuses it on subsequent calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "owner")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add readme"])
|
||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
const tool = yield* init()
|
||||
const cloned = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx))
|
||||
const cached = yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
tool.execute({ repository: "https://github.com/owner/repo.git" }, ctx),
|
||||
)
|
||||
|
||||
expect(cloned.metadata.status).toBe("cloned")
|
||||
expect(cloned.metadata.localPath).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
|
||||
expect(cached.metadata.status).toBe("cached")
|
||||
expect(yield* fs.readFileString(path.join(cloned.metadata.localPath, "README.md"))).toBe("v1\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("refresh updates an existing cached clone", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "owner")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v1\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add readme"])
|
||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
const branch = yield* git(source, ["branch", "--show-current"])
|
||||
yield* git(source, ["remote", "add", "origin", remoteRepo])
|
||||
yield* git(source, ["push", "-u", "origin", `${branch}:${branch}`])
|
||||
|
||||
const tool = yield* init()
|
||||
const first = yield* githubBase(`file://${remoteRoot}/`, tool.execute({ repository: "owner/repo" }, ctx))
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "v2\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "update readme"])
|
||||
yield* git(source, ["push", "origin", `${branch}:${branch}`])
|
||||
|
||||
const refreshed = yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
tool.execute({ repository: "owner/repo", refresh: true }, ctx),
|
||||
)
|
||||
|
||||
expect(first.metadata.status).toBe("cloned")
|
||||
expect(refreshed.metadata.status).toBe("refreshed")
|
||||
expect(yield* fs.readFileString(path.join(first.metadata.localPath, "README.md"))).toBe("v2\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("clones a configured branch", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "owner")
|
||||
const remoteRepo = path.join(remoteDir, "repo.git")
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "README.md"), "main\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add readme"])
|
||||
yield* git(source, ["checkout", "-b", "docs"])
|
||||
yield* Effect.promise(() => Bun.write(path.join(source, "DOCS.md"), "docs\n"))
|
||||
yield* git(source, ["add", "."])
|
||||
yield* git(source, ["commit", "-m", "add docs"])
|
||||
yield* fs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
|
||||
|
||||
const tool = yield* init()
|
||||
const result = yield* githubBase(
|
||||
`file://${remoteRoot}/`,
|
||||
tool.execute({ repository: "owner/repo", branch: "docs" }, ctx),
|
||||
)
|
||||
|
||||
expect(result.metadata.status).toBe("cloned")
|
||||
expect(result.metadata.branch).toBe("docs")
|
||||
expect(yield* fs.readFileString(path.join(result.metadata.localPath, "DOCS.md"))).toBe("docs\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects invalid repository inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const tool = yield* init()
|
||||
const inputs = [
|
||||
{ repository: "not-a-repo", message: "git URL" },
|
||||
{ repository: "git@github.com:../../../etc/passwd", message: "git URL" },
|
||||
{ repository: "-u:foo/bar", message: "git URL" },
|
||||
{ repository: pathToFileURL(path.join(dir, "local.git")).href, message: "Local file" },
|
||||
]
|
||||
|
||||
yield* Effect.forEach(
|
||||
inputs,
|
||||
(input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* tool.execute({ repository: input.repository }, ctx).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result)) {
|
||||
const error = Cause.squash(result.cause)
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain(input.message)
|
||||
}
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects local file repository URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ repository: pathToFileURL(source).href }, ctx).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result)) {
|
||||
const error = Cause.squash(result.cause)
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain("Local file")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("rejects invalid branch inputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ repository: "owner/repo", branch: "bad..branch" }, ctx).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result)) {
|
||||
const error = Cause.squash(result.cause)
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain(
|
||||
"Branch must contain only alphanumeric characters",
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Git } from "../../src/git"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { RepoOverviewTool } from "../../src/tool/repo_overview"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
callID: "",
|
||||
agent: "scout",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
const init = Effect.fn("RepoOverviewToolTest.init")(function* () {
|
||||
const info = yield* RepoOverviewTool
|
||||
return yield* info.init()
|
||||
})
|
||||
|
||||
describe("tool.repo_overview", () => {
|
||||
it.instance("summarizes a local repository path", () =>
|
||||
Effect.gen(function* () {
|
||||
const repo = yield* tmpdirScoped({ git: true })
|
||||
const fs = yield* AppFileSystem.Service
|
||||
yield* fs.writeWithDirs(
|
||||
path.join(repo, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "example-repo",
|
||||
main: "dist/index.js",
|
||||
module: "dist/index.mjs",
|
||||
types: "dist/index.d.ts",
|
||||
exports: {
|
||||
".": "./dist/index.js",
|
||||
"./server": "./dist/server.js",
|
||||
},
|
||||
bin: {
|
||||
example: "./bin/example.js",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
yield* fs.writeWithDirs(path.join(repo, "bun.lock"), "")
|
||||
yield* fs.writeWithDirs(path.join(repo, "README.md"), "# Example\n")
|
||||
yield* fs.writeWithDirs(path.join(repo, "src", "index.ts"), "export const value = 1\n")
|
||||
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ path: repo }, ctx)
|
||||
|
||||
expect(result.metadata.path).toBe(repo)
|
||||
expect(result.metadata.ecosystems).toContain("Node.js")
|
||||
expect(result.metadata.package_manager).toBe("bun")
|
||||
expect(result.metadata.dependency_files).toEqual(expect.arrayContaining(["package.json", "bun.lock"]))
|
||||
expect(result.metadata.entrypoints).toEqual(
|
||||
expect.arrayContaining([
|
||||
"main: dist/index.js",
|
||||
"module: dist/index.mjs",
|
||||
"types: dist/index.d.ts",
|
||||
"exports: .",
|
||||
"exports: ./server",
|
||||
"bin: example",
|
||||
"file: src/index.ts",
|
||||
]),
|
||||
)
|
||||
expect(result.output).toContain("Top-level structure:")
|
||||
expect(result.output).toContain("src/")
|
||||
expect(result.output).toContain("README.md")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("resolves relative paths from the instance directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = (yield* TestInstance).directory
|
||||
const fs = yield* AppFileSystem.Service
|
||||
yield* fs.writeWithDirs(path.join(dir, "nested", "README.md"), "# Nested\n")
|
||||
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ path: "nested" }, ctx)
|
||||
|
||||
expect(result.metadata.path).toBe(path.join(dir, "nested"))
|
||||
expect(result.output).toContain("README.md")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("resolves a cached repository from repository shorthand", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const cached = path.join(Global.Path.repos, "github.com", "owner", "repo")
|
||||
yield* fs.writeWithDirs(path.join(cached, "package.json"), JSON.stringify({ name: "cached-repo" }, null, 2))
|
||||
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
|
||||
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ repository: "owner/repo" }, ctx)
|
||||
|
||||
expect(result.metadata.path).toBe(cached)
|
||||
expect(result.metadata.repository).toBe("owner/repo")
|
||||
expect(result.output).toContain("Repository: owner/repo")
|
||||
expect(result.output).toContain(`Path: ${cached}`)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("fails clearly when a repository is not cloned", () =>
|
||||
Effect.gen(function* () {
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ repository: "missing/repo" }, ctx).pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
if (Exit.isFailure(result)) {
|
||||
const error = Cause.squash(result.cause)
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain("Use repo_clone first")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("resolves cached repositories from host/path references", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const cached = path.join(Global.Path.repos, "gitlab.com", "group", "repo")
|
||||
yield* fs.writeWithDirs(path.join(cached, "README.md"), "cached\n")
|
||||
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ repository: "gitlab.com/group/repo" }, ctx)
|
||||
|
||||
expect(result.metadata.path).toBe(cached)
|
||||
expect(result.metadata.repository).toBe("gitlab.com/group/repo")
|
||||
expect(result.output).toContain("Repository: gitlab.com/group/repo")
|
||||
}),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue