Apply PR #24149: feat(core): add scout agent for repo research
This commit is contained in:
commit
04e06e2dfd
36 changed files with 1168 additions and 50 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, test, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
|
|
@ -31,6 +32,7 @@ test("returns default native agents when no config", async () => {
|
|||
expect(names).toContain("plan")
|
||||
expect(names).toContain("general")
|
||||
expect(names).toContain("explore")
|
||||
expect(names).toContain("scout")
|
||||
expect(names).toContain("compaction")
|
||||
expect(names).toContain("title")
|
||||
expect(names).toContain("summary")
|
||||
|
|
@ -49,6 +51,8 @@ test("build agent has correct default properties", async () => {
|
|||
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")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -97,6 +101,28 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
|
|||
})
|
||||
})
|
||||
|
||||
test("scout agent allows repo cloning and repo cache reads", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const scout = await load(tmp.path, (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.data, "repos", "github.com", "owner", "repo", "README.md"),
|
||||
scout!.permission,
|
||||
).action,
|
||||
).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("general agent denies todo tools", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
|
|
|||
|
|
@ -25,6 +25,16 @@ test("parses ssh:// URL without .git suffix", () => {
|
|||
expect(parseGitHubRemote("ssh://git@github.com/sst/opencode")).toEqual({ owner: "sst", repo: "opencode" })
|
||||
})
|
||||
|
||||
test("parses git protocol URLs from package metadata", () => {
|
||||
expect(parseGitHubRemote("git://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
|
||||
expect(parseGitHubRemote("git+https://github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
|
||||
expect(parseGitHubRemote("git+ssh://git@github.com/facebook/react.git")).toEqual({ owner: "facebook", repo: "react" })
|
||||
})
|
||||
|
||||
test("parses npm-style github shorthand", () => {
|
||||
expect(parseGitHubRemote("github:facebook/react")).toBeNull()
|
||||
})
|
||||
|
||||
test("parses http URL", () => {
|
||||
expect(parseGitHubRemote("http://github.com/owner/repo")).toEqual({ owner: "owner", repo: "repo" })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { Permission } from "../../src/permission"
|
|||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "@/provider/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Git } from "../../src/git"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Question } from "../../src/question"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
|
|
@ -175,6 +176,7 @@ function makeHttp() {
|
|||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { TestLLMServer } from "../lib/llm-server"
|
|||
// Same layer setup as prompt-effect.test.ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Git } from "../../src/git"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
import { Config } from "@/config/config"
|
||||
|
|
@ -129,6 +130,7 @@ function makeHttp() {
|
|||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provideMerge(todo),
|
||||
|
|
|
|||
198
packages/opencode/test/tool/repo_clone.test.ts
Normal file
198
packages/opencode/test/tool/repo_clone.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Git } from "../../src/git"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { RepoCloneTool } from "../../src/tool/repo_clone"
|
||||
import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
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("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.live("clones a repo into the managed cache and reuses it on subsequent calls", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
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.data, "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.live("refresh updates an existing cached clone", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
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.live("rejects invalid repository inputs", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = yield* init()
|
||||
const result = yield* tool.execute({ repository: "not-a-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("git URL")
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("clones generic git URLs into the managed cache", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const source = yield* tmpdirScoped({ git: true })
|
||||
const remoteRoot = yield* tmpdirScoped()
|
||||
const remoteDir = path.join(remoteRoot, "forge")
|
||||
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 result = yield* tool.execute({ repository: pathToFileURL(remoteRepo).href }, ctx)
|
||||
|
||||
expect(result.metadata.status).toBe("cloned")
|
||||
expect(result.metadata.host).toBe("file")
|
||||
expect(result.metadata.localPath.startsWith(path.join(Global.Path.data, "repos", "file"))).toBe(true)
|
||||
expect(result.metadata.localPath.endsWith(path.join("forge", "repo"))).toBe(true)
|
||||
expect(yield* fs.readFileString(path.join(result.metadata.localPath, "README.md"))).toBe("v1\n")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
151
packages/opencode/test/tool/repo_overview.test.ts
Normal file
151
packages/opencode/test/tool/repo_overview.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Git } from "../../src/git"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { RepoOverviewTool } from "../../src/tool/repo_overview"
|
||||
import { provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
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.live("summarizes a local repository path", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
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.live("resolves a cached repository from repository shorthand", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const cached = path.join(Global.Path.data, "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.live("fails clearly when a repository is not cloned", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
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.live("resolves cached repositories from host/path references", () =>
|
||||
provideTmpdirInstance((_dir) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const cached = path.join(Global.Path.data, "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