chore(opencode): remove scout agent (#30435)
This commit is contained in:
@@ -115,8 +115,6 @@ export const Plugin = PluginV2.define({
|
|||||||
{ action: "question", resource: "*", effect: "deny" },
|
{ action: "question", resource: "*", effect: "deny" },
|
||||||
{ action: "plan_enter", resource: "*", effect: "deny" },
|
{ action: "plan_enter", resource: "*", effect: "deny" },
|
||||||
{ action: "plan_exit", 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: "*", effect: "allow" },
|
||||||
{ action: "read", resource: "*.env", effect: "ask" },
|
{ action: "read", resource: "*.env", effect: "ask" },
|
||||||
{ 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:
|
Tests should vary behavior with explicit layer variants:
|
||||||
|
|
||||||
```ts
|
```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.
|
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
|
- [x] Built-in websearch provider selection uses the same runtime flags as
|
||||||
tool visibility.
|
tool visibility.
|
||||||
- [x] Removed global default-plugin disabling from test preload.
|
- [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-2` Plan-mode prompt read routed through runtime flags (#27320).
|
||||||
- [x] `RF-3` Event-system reads routed through runtime flags (#27323).
|
- [x] `RF-3` Event-system reads routed through runtime flags (#27323).
|
||||||
- [x] `RF-4` Workspaces reads routed through runtime flags for session
|
- [x] `RF-4` Workspaces reads routed through runtime flags for session
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ export function toToolKind(toolName: string): ToolKind {
|
|||||||
|
|
||||||
case "grep":
|
case "grep":
|
||||||
case "glob":
|
case "glob":
|
||||||
case "repo_clone":
|
|
||||||
case "repo_overview":
|
|
||||||
case "context":
|
case "context":
|
||||||
case "context7_resolve_library_id":
|
case "context7_resolve_library_id":
|
||||||
case "context7_get_library_docs":
|
case "context7_get_library_docs":
|
||||||
@@ -78,8 +76,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
|
|||||||
|
|
||||||
case "grep":
|
case "grep":
|
||||||
case "glob":
|
case "glob":
|
||||||
case "repo_clone":
|
|
||||||
case "repo_overview":
|
|
||||||
case "context":
|
case "context":
|
||||||
case "context7_resolve_library_id":
|
case "context7_resolve_library_id":
|
||||||
case "context7_get_library_docs":
|
case "context7_get_library_docs":
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { ProviderTransform } from "@/provider/transform"
|
|||||||
import PROMPT_GENERATE from "./generate.txt"
|
import PROMPT_GENERATE from "./generate.txt"
|
||||||
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
import PROMPT_COMPACTION from "./prompt/compaction.txt"
|
||||||
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
import PROMPT_EXPLORE from "./prompt/explore.txt"
|
||||||
import PROMPT_SCOUT from "./prompt/scout.txt"
|
|
||||||
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
import PROMPT_SUMMARY from "./prompt/summary.txt"
|
||||||
import PROMPT_TITLE from "./prompt/title.txt"
|
import PROMPT_TITLE from "./prompt/title.txt"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
@@ -22,7 +21,6 @@ import { Plugin } from "@/plugin"
|
|||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Effect, Context, Layer, Schema } from "effect"
|
import { Effect, Context, Layer, Schema } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
import { type DeepMutable } from "@opencode-ai/core/schema"
|
import { type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
@@ -89,7 +87,6 @@ export const layer = Layer.effect(
|
|||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("Agent.state")(function* (ctx) {
|
Effect.fn("Agent.state")(function* (ctx) {
|
||||||
@@ -115,8 +112,6 @@ export const layer = Layer.effect(
|
|||||||
question: "deny",
|
question: "deny",
|
||||||
plan_enter: "deny",
|
plan_enter: "deny",
|
||||||
plan_exit: "deny",
|
plan_exit: "deny",
|
||||||
repo_clone: "deny",
|
|
||||||
repo_overview: "deny",
|
|
||||||
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
|
||||||
read: {
|
read: {
|
||||||
"*": "allow",
|
"*": "allow",
|
||||||
@@ -204,36 +199,6 @@ export const layer = Layer.effect(
|
|||||||
mode: "subagent",
|
mode: "subagent",
|
||||||
native: true,
|
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: {
|
compaction: {
|
||||||
name: "compaction",
|
name: "compaction",
|
||||||
mode: "primary",
|
mode: "primary",
|
||||||
@@ -462,7 +427,6 @@ export const defaultLayer = layer.pipe(
|
|||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export * as Agent from "./agent"
|
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: ${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")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ export const Info = Schema.Struct({
|
|||||||
// subagent
|
// subagent
|
||||||
general: Schema.optional(ConfigAgent.Info),
|
general: Schema.optional(ConfigAgent.Info),
|
||||||
explore: Schema.optional(ConfigAgent.Info),
|
explore: Schema.optional(ConfigAgent.Info),
|
||||||
scout: Schema.optional(ConfigAgent.Info),
|
|
||||||
// specialized
|
// specialized
|
||||||
title: Schema.optional(ConfigAgent.Info),
|
title: Schema.optional(ConfigAgent.Info),
|
||||||
summary: Schema.optional(ConfigAgent.Info),
|
summary: Schema.optional(ConfigAgent.Info),
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ const InputObject = Schema.StructWithRest(
|
|||||||
question: Schema.optional(Action),
|
question: Schema.optional(Action),
|
||||||
webfetch: Schema.optional(Action),
|
webfetch: Schema.optional(Action),
|
||||||
websearch: Schema.optional(Action),
|
websearch: Schema.optional(Action),
|
||||||
repo_clone: Schema.optional(Rule),
|
|
||||||
repo_overview: Schema.optional(Rule),
|
|
||||||
lsp: Schema.optional(Rule),
|
lsp: Schema.optional(Rule),
|
||||||
doom_loop: Schema.optional(Action),
|
doom_loop: Schema.optional(Action),
|
||||||
skill: Schema.optional(Rule),
|
skill: Schema.optional(Rule),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Git = Schema.Struct({
|
|||||||
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
|
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
|
||||||
}),
|
}),
|
||||||
branch: Schema.optional(Schema.String).annotate({
|
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)),
|
}).pipe(Config.map((flags) => flags.enabled || flags.legacy)),
|
||||||
enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
|
enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
|
||||||
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
|
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||||
experimentalScout: enabledByExperimental("OPENCODE_EXPERIMENTAL_SCOUT"),
|
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
|
||||||
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
|
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
|
||||||
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
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[] }) {
|
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(
|
return Effect.forEach(
|
||||||
input.materializers,
|
input.materializers,
|
||||||
Effect.fnUntraced(function* (item) {
|
Effect.fnUntraced(function* (item) {
|
||||||
@@ -205,7 +205,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
init: Effect.fn("Reference.init")(function* () {
|
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)
|
yield* InstanceState.useEffect(state, (s) => s.materializeAll).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("Reference.list")(function* () {
|
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))
|
return yield* InstanceState.use(state, (s) => s.references.find((reference) => reference.name === name))
|
||||||
}),
|
}),
|
||||||
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
|
ensure: Effect.fn("Reference.ensure")(function* (target?: string) {
|
||||||
if (!flags.experimentalScout) return
|
if (!flags.experimentalReferences) return
|
||||||
const full = normalizedTarget(target)
|
const full = normalizedTarget(target)
|
||||||
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
if (!full) return yield* InstanceState.useEffect(state, (s) => s.materializeAll)
|
||||||
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
|
return yield* InstanceState.useEffect(state, (s) => materializeByPath(s.materializeByPath, full))
|
||||||
}),
|
}),
|
||||||
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
contains: Effect.fn("Reference.contains")(function* (target?: string) {
|
||||||
if (!flags.experimentalScout) return false
|
if (!flags.experimentalReferences) return false
|
||||||
const full = normalizedTarget(target)
|
const full = normalizedTarget(target)
|
||||||
if (!full) return false
|
if (!full) return false
|
||||||
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
|
return yield* InstanceState.use(state, (s) => containsGitReferencePath(s.references, full))
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function referenceTextPart(input: {
|
|||||||
...(metadata.problem
|
...(metadata.problem
|
||||||
? [`Problem: ${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"),
|
].join("\n"),
|
||||||
metadata: { reference: metadata },
|
metadata: { reference: metadata },
|
||||||
|
|||||||
@@ -227,8 +227,7 @@ file, `disable: true` in frontmatter.
|
|||||||
|
|
||||||
### Built-in agents
|
### Built-in agents
|
||||||
|
|
||||||
opencode ships with `build`, `plan`, `general`, `explore`, plus optionally
|
opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents:
|
||||||
`scout` (gated on `OPENCODE_EXPERIMENTAL_SCOUT`). Hidden internal agents:
|
|
||||||
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
`compaction`, `title`, `summary`. To override a built-in's fields, define the
|
||||||
same key in `agent: { <name>: { ... } }`.
|
same key in `agent: { <name>: { ... } }`.
|
||||||
|
|
||||||
@@ -335,8 +334,8 @@ rules last.
|
|||||||
everything" and is rarely what the user wants.
|
everything" and is rarely what the user wants.
|
||||||
|
|
||||||
Known permission keys: `read, edit, glob, grep, list, bash, task,
|
Known permission keys: `read, edit, glob, grep, list, bash, task,
|
||||||
external_directory, todowrite, question, webfetch, websearch, repo_clone,
|
external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop,
|
||||||
repo_overview, lsp, doom_loop, skill`. Some of these (`todowrite,
|
skill`. Some of these (`todowrite,
|
||||||
question, webfetch, websearch, doom_loop`) only accept a flat
|
question, webfetch, websearch, doom_loop`) only accept a flat
|
||||||
action, not a per-pattern object.
|
action, not a per-pattern object.
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ import { Plugin } from "../plugin"
|
|||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
|
|
||||||
import { WebSearchTool } from "./websearch"
|
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 * as Log from "@opencode-ai/core/util/log"
|
||||||
import { LspTool } from "./lsp"
|
import { LspTool } from "./lsp"
|
||||||
import * as Truncate from "./truncate"
|
import * as Truncate from "./truncate"
|
||||||
@@ -48,7 +45,6 @@ import { Instruction } from "../session/instruction"
|
|||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Git } from "@/git"
|
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Reference } from "@/reference/reference"
|
import { Reference } from "@/reference/reference"
|
||||||
@@ -97,8 +93,6 @@ export const layer: Layer.Layer<
|
|||||||
| Session.Service
|
| Session.Service
|
||||||
| BackgroundJob.Service
|
| BackgroundJob.Service
|
||||||
| Provider.Service
|
| Provider.Service
|
||||||
| Git.Service
|
|
||||||
| RepositoryCache.Service
|
|
||||||
| Reference.Service
|
| Reference.Service
|
||||||
| LSP.Service
|
| LSP.Service
|
||||||
| Instruction.Service
|
| Instruction.Service
|
||||||
@@ -130,8 +124,6 @@ export const layer: Layer.Layer<
|
|||||||
const plan = yield* PlanExitTool
|
const plan = yield* PlanExitTool
|
||||||
const webfetch = yield* WebFetchTool
|
const webfetch = yield* WebFetchTool
|
||||||
const websearch = yield* WebSearchTool
|
const websearch = yield* WebSearchTool
|
||||||
const repoClone = yield* RepoCloneTool
|
|
||||||
const repoOverview = yield* RepoOverviewTool
|
|
||||||
const shell = yield* ShellTool
|
const shell = yield* ShellTool
|
||||||
const globtool = yield* GlobTool
|
const globtool = yield* GlobTool
|
||||||
const writetool = yield* WriteTool
|
const writetool = yield* WriteTool
|
||||||
@@ -241,8 +233,6 @@ export const layer: Layer.Layer<
|
|||||||
fetch: Tool.init(webfetch),
|
fetch: Tool.init(webfetch),
|
||||||
todo: Tool.init(todo),
|
todo: Tool.init(todo),
|
||||||
search: Tool.init(websearch),
|
search: Tool.init(websearch),
|
||||||
repo_clone: Tool.init(repoClone),
|
|
||||||
repo_overview: Tool.init(repoOverview),
|
|
||||||
skill: Tool.init(skilltool),
|
skill: Tool.init(skilltool),
|
||||||
patch: Tool.init(patchtool),
|
patch: Tool.init(patchtool),
|
||||||
question: Tool.init(question),
|
question: Tool.init(question),
|
||||||
@@ -265,7 +255,6 @@ export const layer: Layer.Layer<
|
|||||||
tool.fetch,
|
tool.fetch,
|
||||||
tool.todo,
|
tool.todo,
|
||||||
tool.search,
|
tool.search,
|
||||||
...(flags.experimentalScout ? [tool.repo_clone, tool.repo_overview] : []),
|
|
||||||
tool.skill,
|
tool.skill,
|
||||||
tool.patch,
|
tool.patch,
|
||||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||||
@@ -388,7 +377,6 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(BackgroundJob.defaultLayer),
|
Layer.provide(BackgroundJob.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
|
|
||||||
Layer.provide(Reference.defaultLayer),
|
Layer.provide(Reference.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(Instruction.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("write")).toBe("edit")
|
||||||
expect(toToolKind("grep")).toBe("search")
|
expect(toToolKind("grep")).toBe("search")
|
||||||
expect(toToolKind("glob")).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_resolve_library_id")).toBe("search")
|
||||||
expect(toToolKind("context7_get_library_docs")).toBe("search")
|
expect(toToolKind("context7_get_library_docs")).toBe("search")
|
||||||
expect(toToolKind("read")).toBe("read")
|
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("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
|
||||||
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
|
||||||
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
|
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("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
|
||||||
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
|
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
|
||||||
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).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 it = testEffect(agentLayer())
|
||||||
const scout = testEffect(agentLayer({ experimentalScout: true }))
|
|
||||||
|
|
||||||
// Helper to evaluate permission for a tool with wildcard pattern
|
// Helper to evaluate permission for a tool with wildcard pattern
|
||||||
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionLegacy.Action | undefined {
|
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("plan")
|
||||||
expect(names).toContain("general")
|
expect(names).toContain("general")
|
||||||
expect(names).toContain("explore")
|
expect(names).toContain("explore")
|
||||||
expect(names).not.toContain("scout")
|
|
||||||
expect(names).toContain("compaction")
|
expect(names).toContain("compaction")
|
||||||
expect(names).toContain("title")
|
expect(names).toContain("title")
|
||||||
expect(names).toContain("summary")
|
expect(names).toContain("summary")
|
||||||
@@ -71,8 +69,6 @@ it.instance("build agent has correct default properties", () =>
|
|||||||
expect(build?.native).toBe(true)
|
expect(build?.native).toBe(true)
|
||||||
expect(evalPerm(build, "edit")).toBe("allow")
|
expect(evalPerm(build, "edit")).toBe("allow")
|
||||||
expect(evalPerm(build, "bash")).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", () =>
|
it.instance(
|
||||||
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(
|
|
||||||
"reference config does not create subagents",
|
"reference config does not create subagents",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* load((svc) => svc.list())
|
const agents = yield* load((svc) => svc.list())
|
||||||
const names = agents.map((agent) => agent.name)
|
const names = agents.map((agent) => agent.name)
|
||||||
expect(names).toContain("scout")
|
|
||||||
expect(names).not.toContain("effect")
|
expect(names).not.toContain("effect")
|
||||||
expect(names).not.toContain("effectFull")
|
expect(names).not.toContain("effectFull")
|
||||||
expect(names).not.toContain("localdocs")
|
expect(names).not.toContain("localdocs")
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe("RuntimeFlags", () => {
|
|||||||
expect(flags.enableParallel).toBe(true)
|
expect(flags.enableParallel).toBe(true)
|
||||||
expect(flags.enableExperimentalModels).toBe(true)
|
expect(flags.enableExperimentalModels).toBe(true)
|
||||||
expect(flags.enableQuestionTool).toBe(true)
|
expect(flags.enableQuestionTool).toBe(true)
|
||||||
expect(flags.experimentalScout).toBe(true)
|
expect(flags.experimentalReferences).toBe(true)
|
||||||
expect(flags.experimentalBackgroundSubagents).toBe(true)
|
expect(flags.experimentalBackgroundSubagents).toBe(true)
|
||||||
expect(flags.experimentalLspTy).toBe(false)
|
expect(flags.experimentalLspTy).toBe(false)
|
||||||
expect(flags.experimentalLspTool).toBe(true)
|
expect(flags.experimentalLspTool).toBe(true)
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ const referenceLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
Layer.mergeAll(AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer, referenceLayer()),
|
||||||
)
|
)
|
||||||
const scout = testEffect(
|
const references = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
AppFileSystem.defaultLayer,
|
AppFileSystem.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Git.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(
|
provideTmpdirInstance(
|
||||||
(_dir) =>
|
(_dir) =>
|
||||||
Effect.gen(function* () {
|
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* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-reference-refresh", "repo")
|
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(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(`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)
|
yield* sessions.remove(session.id)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const it = testEffect(toolLayer())
|
const it = testEffect(toolLayer())
|
||||||
const scout = testEffect(toolLayer({ experimentalScout: true }))
|
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
sessionID: SessionID.make("ses_test"),
|
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",
|
"does not ask for external_directory permission inside configured git references",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const toolLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const it = testEffect(toolLayer())
|
const it = testEffect(toolLayer())
|
||||||
const scout = testEffect(toolLayer({ experimentalScout: true }))
|
const references = testEffect(toolLayer({ experimentalReferences: true }))
|
||||||
const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
|
const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer))
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -215,7 +215,7 @@ describe("tool.grep", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
scout.instance(
|
references.instance(
|
||||||
"does not ask for external_directory permission inside configured git references",
|
"does not ask for external_directory permission inside configured git references",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const readLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer))
|
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 init = Effect.fn("ReadToolTest.init")(function* () {
|
||||||
const info = yield* ReadTool
|
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* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
const cache = path.join(Global.Path.repos, "github.com", "opencode-read-reference", "repo")
|
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 it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||||
const scout = testEffect(
|
|
||||||
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
|
|
||||||
)
|
|
||||||
const withBrokenPlugin = testEffect(
|
const withBrokenPlugin = testEffect(
|
||||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||||
)
|
)
|
||||||
@@ -110,26 +107,6 @@ afterEach(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("tool.registry", () => {
|
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", () =>
|
it.instance("does not expose task_status", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const registry = yield* ToolRegistry.Service
|
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")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user