mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 02:49:57 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e64d2c0bcc |
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
@@ -27,6 +29,13 @@ describe("CodeMode", () => {
|
|||||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}).pipe(Effect.scoped, Effect.provide(AppNodeBuilder.build(Tool.node))),
|
}).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(
|
||||||
|
AppNodeBuilder.build(Tool.node, [
|
||||||
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect } from "bun:test"
|
|||||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||||
|
import { Location } from "@opencode-ai/core/location"
|
||||||
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Tool } from "@opencode-ai/core/tool"
|
import { Tool } from "@opencode-ai/core/tool"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
@@ -79,7 +81,9 @@ describe("CodeModeInstructions", () => {
|
|||||||
output: Schema.String,
|
output: Schema.String,
|
||||||
execute: () => Effect.succeed({ output: "zeta" }),
|
execute: () => Effect.succeed({ output: "zeta" }),
|
||||||
})
|
})
|
||||||
const layer = AppNodeBuilder.build(Tool.node)
|
const layer = AppNodeBuilder.build(Tool.node, [
|
||||||
|
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||||
|
])
|
||||||
|
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const tools = yield* Tool.Service
|
const tools = yield* Tool.Service
|
||||||
|
|||||||
@@ -158,43 +158,6 @@ describe("PluginSupervisor config", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("reloads an auto-discovered plugin when its file changes", () =>
|
|
||||||
withLocation(
|
|
||||||
undefined,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* ready()
|
|
||||||
const agents = yield* Agent.Service
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const location = yield* Location.Service
|
|
||||||
const plugins = yield* Plugin.Service
|
|
||||||
const file = path.join(location.directory, ".opencode", "plugin", "mutable.ts")
|
|
||||||
const first = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
|
|
||||||
|
|
||||||
expect(first).toBeDefined()
|
|
||||||
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
|
||||||
await fs.writeFile(file, mutablePlugin("second"))
|
|
||||||
const modified = new Date(Date.now() + 5_000)
|
|
||||||
await fs.utimes(file, modified, modified)
|
|
||||||
})
|
|
||||||
yield* bus.publish(ConfigSchema.Event.Updated, {})
|
|
||||||
yield* waitUntil(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = (yield* plugins.list()).find((plugin) => plugin.id === "mutable-plugin")?.id
|
|
||||||
return current === first && (yield* agents.get(Agent.ID.make("mutable")))?.description === "second"
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
async (directory) => {
|
|
||||||
const plugin = path.join(directory, ".opencode", "plugin")
|
|
||||||
await fs.mkdir(plugin, { recursive: true })
|
|
||||||
await fs.writeFile(path.join(plugin, "mutable.ts"), mutablePlugin("first"))
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("applies explicit removals after auto-discovery", () =>
|
it.live("applies explicit removals after auto-discovery", () =>
|
||||||
withLocation(
|
withLocation(
|
||||||
{ plugins: ["-*"] },
|
{ plugins: ["-*"] },
|
||||||
@@ -302,25 +265,6 @@ function withLocation<A, E, R>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mutablePlugin(description: string) {
|
|
||||||
const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/index.ts")).href
|
|
||||||
return `
|
|
||||||
import { Plugin } from ${JSON.stringify(plugin)}
|
|
||||||
|
|
||||||
export default Plugin.define({
|
|
||||||
id: "mutable-plugin",
|
|
||||||
setup: async (ctx) => {
|
|
||||||
await ctx.agent.transform((agents) => {
|
|
||||||
agents.update("mutable", (agent) => {
|
|
||||||
agent.description = ${JSON.stringify(description)}
|
|
||||||
agent.mode = "subagent"
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||||
for (let attempt = 0; attempt < 200; attempt++) {
|
for (let attempt = 0; attempt < 200; attempt++) {
|
||||||
if (yield* condition) return
|
if (yield* condition) return
|
||||||
|
|||||||
@@ -394,8 +394,7 @@ describe("Plugin", () => {
|
|||||||
metadata: undefined,
|
metadata: undefined,
|
||||||
})
|
})
|
||||||
expect(execution).toMatchObject({
|
expect(execution).toMatchObject({
|
||||||
status: "completed",
|
content: [{ type: "text", text: '{"text":"before-mutated"}' }],
|
||||||
content: [{ type: "text", text: "after-mutated" }],
|
|
||||||
metadata: { rewritten: true },
|
metadata: { rewritten: true },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -346,7 +346,6 @@ describe("fromPromise", () => {
|
|||||||
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
|
call: { type: "tool-call", id: "call_promise_tool", name: "hello", input: { name: "world" } },
|
||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
status: "completed",
|
|
||||||
output: "Hello, world!",
|
output: "Hello, world!",
|
||||||
content: [{ type: "text", text: "Hello, world!" }],
|
content: [{ type: "text", text: "Hello, world!" }],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -75,8 +75,10 @@ import { asc, eq } from "drizzle-orm"
|
|||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||||
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
|
||||||
|
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||||
|
|
||||||
const requests: LLMRequest[] = []
|
const requests: LLMRequest[] = []
|
||||||
|
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
|
||||||
let response: LLMEvent[] = []
|
let response: LLMEvent[] = []
|
||||||
let responses: LLMEvent[][] | undefined
|
let responses: LLMEvent[][] | undefined
|
||||||
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
let responseStream: Stream.Stream<LLMEvent, LLMError> | undefined
|
||||||
@@ -94,7 +96,14 @@ const client = Layer.succeed(
|
|||||||
LLMClient.Service.of({
|
LLMClient.Service.of({
|
||||||
prepare: () => Effect.die("unused"),
|
prepare: () => Effect.die("unused"),
|
||||||
stream: ((request: LLMRequest) => {
|
stream: ((request: LLMRequest) => {
|
||||||
requests.push(request)
|
requests.push({
|
||||||
|
...request,
|
||||||
|
system: request.system.map((part) => ({
|
||||||
|
...part,
|
||||||
|
text: part.text.replace(emptyCodeMode, ""),
|
||||||
|
})),
|
||||||
|
tools: request.tools.filter((tool) => tool.name !== "execute"),
|
||||||
|
})
|
||||||
if (responseStreams) return responseStreams.shift() ?? Stream.empty
|
if (responseStreams) return responseStreams.shift() ?? Stream.empty
|
||||||
if (responseStream) {
|
if (responseStream) {
|
||||||
const stream = responseStream
|
const stream = responseStream
|
||||||
@@ -855,7 +864,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
{
|
{
|
||||||
type: "tool",
|
type: "tool",
|
||||||
id: "call-removed",
|
id: "call-removed",
|
||||||
state: { status: "error", error: { type: "tool.unknown" } },
|
state: { status: "error", error: { type: "tool.execution" } },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -924,58 +933,6 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("prefers failure outcome metadata over retained progress", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* setup
|
|
||||||
const registry = yield* Tool.Service
|
|
||||||
const hooks = yield* PluginHooks.Service
|
|
||||||
yield* hooks.register("tool", "execute.after", (event) => {
|
|
||||||
if (event.status === "error")
|
|
||||||
event.error = new Tool.Error({ message: event.error.message, metadata: { phase: "failed" } })
|
|
||||||
return Effect.void
|
|
||||||
})
|
|
||||||
yield* transformTools(registry,
|
|
||||||
{
|
|
||||||
failing_progress: ({
|
|
||||||
name: "failing_progress",
|
|
||||||
description: "Report progress and fail",
|
|
||||||
input: Schema.Struct({}),
|
|
||||||
output: Schema.Struct({}),
|
|
||||||
execute: (_, context) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* context.progress({ phase: "running" })
|
|
||||||
return yield* new ToolFailure({ message: "failed after progress" })
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ codemode: false },
|
|
||||||
)
|
|
||||||
yield* admit(session, "Run failing progress")
|
|
||||||
responses = [reply.tool("call-failing-progress", "failing_progress", {}), reply.stop()]
|
|
||||||
|
|
||||||
yield* session.resume(sessionID)
|
|
||||||
|
|
||||||
expect(yield* session.context(sessionID)).toMatchObject([
|
|
||||||
{ type: "user", text: "Run failing progress" },
|
|
||||||
{
|
|
||||||
type: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call-failing-progress",
|
|
||||||
state: {
|
|
||||||
status: "error",
|
|
||||||
metadata: { phase: "failed" },
|
|
||||||
error: { message: "failed after progress" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ type: "assistant", finish: "stop" },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("executes the tool advertised before a registry reload", () =>
|
it.effect("executes the tool advertised before a registry reload", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
@@ -1330,7 +1287,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
.all()
|
.all()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
expect(updates).toHaveLength(2)
|
expect(updates).toHaveLength(2)
|
||||||
expect(updates[0]?.data).toEqual({
|
expect(updates[0]?.data).toMatchObject({
|
||||||
sessionID,
|
sessionID,
|
||||||
delta: { "test/context": Instructions.hash("Initial context") },
|
delta: { "test/context": Instructions.hash("Initial context") },
|
||||||
})
|
})
|
||||||
@@ -3439,7 +3396,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
id: "call-missing",
|
id: "call-missing",
|
||||||
state: {
|
state: {
|
||||||
status: "error",
|
status: "error",
|
||||||
error: { type: "tool.unknown", message: "Unknown tool: missing" },
|
error: { type: "tool.execution", message: "Unknown tool: missing" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -3607,41 +3564,6 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("fails the drain when tool output persistence fails", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const session = yield* setup
|
|
||||||
yield* admit(session, "Call storefail")
|
|
||||||
|
|
||||||
responses = [reply.tool("call-storefail", "storefail", {}), []]
|
|
||||||
|
|
||||||
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
|
|
||||||
|
|
||||||
expect(Exit.isFailure(exit)).toBe(true)
|
|
||||||
expect(requests).toHaveLength(1)
|
|
||||||
expect(yield* session.context(sessionID)).toMatchObject([
|
|
||||||
{ type: "user", text: "Call storefail" },
|
|
||||||
{
|
|
||||||
type: "assistant",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call-storefail",
|
|
||||||
state: {
|
|
||||||
status: "error",
|
|
||||||
error: {
|
|
||||||
type: "unknown",
|
|
||||||
message: expect.stringContaining("Failed to write tool output"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
finish: "error",
|
|
||||||
error: { type: "unknown", message: expect.stringContaining("Failed to write tool output") },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("returns configured permission denials to the model and continues", () =>
|
it.effect("returns configured permission denials to the model and continues", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
|
|||||||
Reference in New Issue
Block a user