mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5b22986b4 |
@@ -75,9 +75,7 @@ export const create = (
|
||||
const outputFileParts = outputFiles(content)
|
||||
if (outputFileParts.length > 0)
|
||||
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
|
||||
if (executed.output !== undefined) return executed.output
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return text === "" ? null : text
|
||||
return executed.output
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) => {
|
||||
@@ -157,7 +155,7 @@ function runtime(
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
output: child.outputSchema ?? Schema.NullOr(Schema.String),
|
||||
output: child.outputSchema,
|
||||
execute: (input) => executeTool(name, registration, input),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ const snapshotConfigFile = "opencode.gitconfig"
|
||||
const snapshotConfigInclude = `[include]
|
||||
path = ${snapshotConfigFile}
|
||||
`
|
||||
const snapshotConfig = `[core]
|
||||
autocrlf = false
|
||||
const snapshotConfig = (input: { autocrlf: string; symlinks: string }) => `[core]
|
||||
autocrlf = ${input.autocrlf}
|
||||
longpaths = true
|
||||
symlinks = true
|
||||
symlinks = ${input.symlinks}
|
||||
fsmonitor = false
|
||||
untrackedCache = true
|
||||
[feature]
|
||||
@@ -341,6 +341,46 @@ const layer = Layer.effect(
|
||||
})
|
||||
})
|
||||
|
||||
const sourceConfig = Effect.fnUntraced(function* (
|
||||
repository: Repository | undefined,
|
||||
key: string,
|
||||
fallback: string,
|
||||
allowInput: boolean,
|
||||
) {
|
||||
if (!repository) return fallback
|
||||
const result = yield* execute(
|
||||
repository.worktree,
|
||||
proc,
|
||||
)(["config", "--get", key]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new OperationError({
|
||||
operation: "create",
|
||||
directory: repository.worktree,
|
||||
message: `Failed to resolve ${key}`,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) {
|
||||
const value = result.text.trim().toLowerCase()
|
||||
if (allowInput && value === "input") return value
|
||||
if (["true", "yes", "on", "1"].includes(value)) return "true"
|
||||
if (["false", "no", "off", "0"].includes(value)) return "false"
|
||||
return yield* new OperationError({
|
||||
operation: "create",
|
||||
directory: repository.worktree,
|
||||
message: `Invalid ${key} value: ${value}`,
|
||||
})
|
||||
}
|
||||
if (result.exitCode === 1) return fallback
|
||||
return yield* new OperationError({
|
||||
operation: "create",
|
||||
directory: repository.worktree,
|
||||
message: result.stderr.trim() || `Failed to resolve ${key}`,
|
||||
})
|
||||
})
|
||||
|
||||
const create = Effect.fn("Git.repo.create")(function* (input: {
|
||||
worktree: AbsolutePath
|
||||
gitDirectory: AbsolutePath
|
||||
@@ -363,14 +403,20 @@ const layer = Layer.effect(
|
||||
commonDirectory: input.gitDirectory,
|
||||
})
|
||||
yield* repositoryOperation("create", repository, ["init"])
|
||||
yield* Effect.gen(function* () {
|
||||
yield* fs.writeFileString(path.join(input.gitDirectory, snapshotConfigFile), snapshotConfig)
|
||||
const semantics = {
|
||||
autocrlf: yield* sourceConfig(input.seed, "core.autocrlf", "false", true),
|
||||
symlinks: yield* sourceConfig(input.seed, "core.symlinks", "true", false),
|
||||
}
|
||||
const reseed = yield* Effect.gen(function* () {
|
||||
const owned = path.join(input.gitDirectory, snapshotConfigFile)
|
||||
const desired = snapshotConfig(semantics)
|
||||
const previous = yield* fs.readFileString(owned).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
yield* fs.writeFileString(owned, desired)
|
||||
const config = path.join(input.gitDirectory, "config")
|
||||
const current = yield* fs.readFileString(config)
|
||||
if (current.includes(snapshotConfigInclude)) return
|
||||
yield* fs.writeFileString(config, `${current.endsWith("\n") ? "\n" : "\n\n"}${snapshotConfigInclude}`, {
|
||||
flag: "a",
|
||||
})
|
||||
const base = current.replace(/^\s*path\s*=\s*opencode\.gitconfig\s*\r?\n?/gm, "").trimEnd()
|
||||
yield* fs.writeFileString(config, `${base}\n\n${snapshotConfigInclude}`)
|
||||
return previous !== desired
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
@@ -410,6 +456,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (!reseed) return repository
|
||||
yield* fs
|
||||
.copyFile(path.join(input.seed.gitDirectory, "index"), path.join(input.gitDirectory, "index"))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
|
||||
@@ -83,11 +83,9 @@ const layer = Layer.effect(
|
||||
const gitDirectory = AbsolutePath.make(
|
||||
path.join(global.data, "snapshot", location.project.id, Hash.fast(worktree)),
|
||||
)
|
||||
const snapshotRepository = (yield* fs.existsSafe(path.join(gitDirectory, "HEAD")))
|
||||
? new Git.Repository({ worktree, gitDirectory, commonDirectory: gitDirectory })
|
||||
: yield* git.repo
|
||||
.create({ worktree, gitDirectory, seed: source })
|
||||
.pipe(Effect.mapError((cause) => failure("capture", cause)))
|
||||
const snapshotRepository = yield* git.repo
|
||||
.create({ worktree, gitDirectory, seed: source })
|
||||
.pipe(Effect.mapError((cause) => failure("capture", cause)))
|
||||
return { source, worktree, snapshotRepository }
|
||||
}).pipe(Effect.forkIn(lifetime)),
|
||||
)
|
||||
|
||||
@@ -103,14 +103,10 @@ type GitOps = ReturnType<typeof makeGit>
|
||||
const cfg = [
|
||||
"--no-optional-locks",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
] as const
|
||||
|
||||
|
||||
@@ -148,21 +148,34 @@ describe("Git trees", () => {
|
||||
const git = yield* Git.Service
|
||||
const source = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!source) throw new Error("Repository not found")
|
||||
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
|
||||
yield* Effect.promise(() => $`git config core.symlinks false`.cwd(root.path).quiet())
|
||||
const storage = AbsolutePath.make(path.join(root.path, ".snapshot storage"))
|
||||
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path first.gitconfig`.quiet())
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config --add include.path second.gitconfig`.quiet())
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf true`.quiet())
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config core.autocrlf false`.quiet())
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config core.symlinks true`.quiet())
|
||||
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
|
||||
expect(
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
|
||||
).toBe("true\n")
|
||||
expect(
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.symlinks`.text()),
|
||||
).toBe("false\n")
|
||||
yield* Effect.promise(() => $`git config core.autocrlf input`.cwd(root.path).quiet())
|
||||
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
|
||||
expect(
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --includes core.autocrlf`.text()),
|
||||
).toBe("input\n")
|
||||
yield* Effect.promise(() => $`git config core.autocrlf true`.cwd(root.path).quiet())
|
||||
yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
|
||||
expect(
|
||||
(yield* Effect.promise(() => fs.readFile(path.join(storage, "config"), "utf8"))).match(/opencode\.gitconfig/g),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
yield* Effect.promise(() => $`git --git-dir ${storage} config --local --get-all include.path`.text()),
|
||||
).toBe("opencode.gitconfig\nfirst.gitconfig\nsecond.gitconfig\n")
|
||||
).toBe("first.gitconfig\nsecond.gitconfig\nopencode.gitconfig\n")
|
||||
yield* git.index.refresh({ repository, scope: RelativePath.make("scope") })
|
||||
const before = yield* git.tree.write(repository)
|
||||
|
||||
|
||||
@@ -248,12 +248,6 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
required: ["ok"],
|
||||
},
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("demo"),
|
||||
name: "status",
|
||||
description: "Status",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new MCP.Tool({
|
||||
server: MCP.ServerName.make("direct"),
|
||||
name: "lookup",
|
||||
@@ -296,13 +290,6 @@ const mcp = Layer.mock(MCP.Service, {
|
||||
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
})
|
||||
if (input.name === "status")
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
return new MCP.ToolResult({
|
||||
server: MCP.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
@@ -997,31 +984,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_content_only"),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_mcp_content_only",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.status({})" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(execution).toMatchObject({
|
||||
output: { output: "hello", toolCalls: [{ tool: "demo.status", status: "completed" }] },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
@@ -393,45 +393,4 @@ describe("fromPromise", () => {
|
||||
expect(progress).toEqual([{ phase: "greeting" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns content-only plugin results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const registry = yield* Tool.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const promisePlugin = define({
|
||||
id: "content-only-tool",
|
||||
setup: async (ctx) => {
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add({
|
||||
name: "demo_status",
|
||||
description: "Returns a status string",
|
||||
input: Schema.Struct({}),
|
||||
execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
|
||||
options: { codemode: true },
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
|
||||
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const throughCodeMode = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_content_only_tool"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_content_only_tool"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_content_only_tool",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo_status({})" },
|
||||
},
|
||||
})
|
||||
expect(throughCodeMode).toMatchObject({
|
||||
output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -127,6 +127,51 @@ describe("Snapshot", () => {
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("repairs existing storage with source repository semantics", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const file = path.join(project, "line-endings.txt")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(file, "before\n")
|
||||
await initGit(project, true)
|
||||
await $`git config core.autocrlf false`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
const git = yield* Git.Service.pipe(Effect.provide(AppNodeBuilder.build(Git.node)))
|
||||
const source = yield* git.repo.discover(AbsolutePath.make(project))
|
||||
if (!source) throw new Error("Repository not found")
|
||||
const location = yield* Location.Service.pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
|
||||
),
|
||||
)
|
||||
yield* git.repo.create({
|
||||
worktree: source.worktree,
|
||||
gitDirectory: AbsolutePath.make(path.join(tmp.path, "snapshot", location.project.id, Hash.fast(project))),
|
||||
seed: source,
|
||||
})
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git config core.autocrlf true`.cwd(project).quiet()
|
||||
await fs.rm(file)
|
||||
await $`git checkout -- line-endings.txt`.cwd(project).quiet()
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
expect(before).toBeDefined()
|
||||
yield* Effect.promise(() => fs.writeFile(file, "before\n"))
|
||||
expect(yield* snapshot.capture()).toBe(before)
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("treats capture outside Git as unavailable", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -93,6 +93,42 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("respects repository line ending configuration", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git config core.autocrlf true`.cwd(directory).quiet()
|
||||
await fs.writeFile(path.join(directory, "line-endings.txt"), "before\n")
|
||||
await commitAll(directory, "line endings")
|
||||
await fs.rm(path.join(directory, "line-endings.txt"))
|
||||
await $`git checkout -- line-endings.txt`.cwd(directory).quiet()
|
||||
})
|
||||
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("respects repository symlink configuration", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
const blob = await $`printf target.txt | git hash-object -w --stdin`.cwd(directory).text()
|
||||
await $`git update-index --add --cacheinfo 120000,${blob.trim()},link.txt`.cwd(directory).quiet()
|
||||
await $`git commit -m symlink`.cwd(directory).quiet()
|
||||
await $`git config core.symlinks false`.cwd(directory).quiet()
|
||||
await $`git checkout-index -f link.txt`.cwd(directory).quiet()
|
||||
})
|
||||
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("caches branch info and publishes HEAD changes", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user