|
|
@@ -3,17 +3,20 @@ import { realpathSync } from "node:fs"
|
|
|
import os from "os"
|
|
|
import path from "path"
|
|
|
import { describe, expect } from "bun:test"
|
|
|
-import { DateTime, Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
|
|
+import { Deferred, Duration, Effect, Fiber, Layer, Scope, Stream } from "effect"
|
|
|
import { Money } from "@opencode-ai/schema/money"
|
|
|
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
|
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
|
-import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
|
|
+import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
|
import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
|
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
|
import { Bus } from "@opencode-ai/core/bus"
|
|
|
+import { Config } from "@opencode-ai/core/config"
|
|
|
+import { Environment } from "@opencode-ai/core/environment"
|
|
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
|
import { Global } from "@opencode-ai/util/global"
|
|
|
import { Location } from "@opencode-ai/core/location"
|
|
|
+import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
|
|
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
|
|
import { Model } from "@opencode-ai/core/model"
|
|
|
import { Provider } from "@opencode-ai/core/provider"
|
|
|
@@ -36,7 +39,7 @@ import { Tool } from "@opencode-ai/core/tool"
|
|
|
import { tmpdir } from "./fixture/tmpdir"
|
|
|
import { tempGlobalLayer } from "./fixture/global"
|
|
|
import { testEffect } from "./lib/effect"
|
|
|
-import { toolIdentity, executeTool, toolDefinitions } from "./lib/tool"
|
|
|
+import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
|
|
|
|
|
const sessionID = Session.ID.make("ses_shell_tool_test")
|
|
|
const sessionModel = Model.Ref.make({ id: Model.ID.make("test"), providerID: Provider.ID.make("test") })
|
|
|
@@ -124,27 +127,42 @@ const executionNode = makeGlobalNode({
|
|
|
deps: [Bus.node, SessionStore.node],
|
|
|
})
|
|
|
|
|
|
-const layer = AppNodeBuilder.build(
|
|
|
- LayerNode.group([
|
|
|
- Database.node,
|
|
|
- Bus.node,
|
|
|
- Job.node,
|
|
|
- Session.node,
|
|
|
- SessionExecution.node,
|
|
|
- PluginRuntime.providerNode,
|
|
|
- LocationServiceMap.node,
|
|
|
- filesystem,
|
|
|
- FSUtil.node,
|
|
|
- Global.node,
|
|
|
- ]),
|
|
|
- [
|
|
|
- [SessionExecution.node, executionNode],
|
|
|
- [Permission.node, permission],
|
|
|
- [Global.node, tempGlobalLayer],
|
|
|
+const shellPluginSupervisor = makeLocationNode({
|
|
|
+ service: PluginSupervisor.Service,
|
|
|
+ layer: Layer.effect(
|
|
|
+ PluginSupervisor.Service,
|
|
|
+ registerToolPlugin(ShellTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
|
|
|
+ ),
|
|
|
+ deps: [
|
|
|
+ Config.node,
|
|
|
+ Environment.node,
|
|
|
+ LocationMutation.node,
|
|
|
+ Permission.node,
|
|
|
+ PluginRuntime.node,
|
|
|
+ Shell.node,
|
|
|
+ Tool.node,
|
|
|
],
|
|
|
-)
|
|
|
+})
|
|
|
|
|
|
-const it = testEffect(layer)
|
|
|
+const nodes = LayerNode.group([
|
|
|
+ Database.node,
|
|
|
+ Bus.node,
|
|
|
+ Job.node,
|
|
|
+ Session.node,
|
|
|
+ SessionExecution.node,
|
|
|
+ PluginRuntime.providerNode,
|
|
|
+ LocationServiceMap.node,
|
|
|
+ filesystem,
|
|
|
+ FSUtil.node,
|
|
|
+ Global.node,
|
|
|
+])
|
|
|
+const replacements = [
|
|
|
+ [SessionExecution.node, executionNode],
|
|
|
+ [Permission.node, permission],
|
|
|
+ [Global.node, tempGlobalLayer],
|
|
|
+] satisfies LayerNode.Replacements
|
|
|
+const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements))
|
|
|
+const it = testEffect(AppNodeBuilder.build(nodes, [...replacements, [PluginSupervisor.node, shellPluginSupervisor]]))
|
|
|
|
|
|
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
|
|
sessionID,
|
|
|
@@ -165,9 +183,6 @@ const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
|
|
|
const timeoutOutputCommand = isWindows
|
|
|
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
|
|
|
: "printf 'before timeout'; sleep 60"
|
|
|
-const steadyProgressCommand = isWindows
|
|
|
- ? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
|
|
|
- : "printf steady; sleep 3.4"
|
|
|
const bodyExitCommand = isWindows
|
|
|
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
|
|
|
: "printf body && exit 7"
|
|
|
@@ -203,42 +218,49 @@ const withSession = <A, E, R>(directory: string, body: (registry: Tool.Interface
|
|
|
})
|
|
|
|
|
|
describe("ShellTool", () => {
|
|
|
- it.live("registers and returns real successful output from the active Location", () =>
|
|
|
- Effect.acquireUseRelease(
|
|
|
- Effect.promise(() => tmpdir()),
|
|
|
- (tmp) => {
|
|
|
- reset()
|
|
|
- return withSession(tmp.path, (registry) =>
|
|
|
- Effect.gen(function* () {
|
|
|
- const definitions = yield* toolDefinitions(registry)
|
|
|
- const definition = definitions.find((tool) => tool.name === "shell")
|
|
|
- expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
|
|
- expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
|
|
- // Code Mode receives the declared output schema, including the command output text.
|
|
|
- expect(definition?.outputSchema).toHaveProperty("properties.output")
|
|
|
- expect(
|
|
|
- (yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
|
|
- (tool) => tool.name,
|
|
|
- ),
|
|
|
- ).not.toContain("shell")
|
|
|
-
|
|
|
- const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
|
|
- expect(settled.status).toBe("completed")
|
|
|
- expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
|
|
- expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
|
|
- expect(settled.content?.[1]).toMatchObject({
|
|
|
- type: "text",
|
|
|
- text: expect.stringContaining("Command exited with code 0."),
|
|
|
- })
|
|
|
- expect(assertions).toMatchObject([
|
|
|
- { sessionID, action: "shell", resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand] },
|
|
|
- ])
|
|
|
- expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
|
|
- }),
|
|
|
- )
|
|
|
- },
|
|
|
- (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
|
- ),
|
|
|
+ productionIt.live(
|
|
|
+ "registers and returns real successful output from the active Location",
|
|
|
+ () =>
|
|
|
+ Effect.acquireUseRelease(
|
|
|
+ Effect.promise(() => tmpdir()),
|
|
|
+ (tmp) => {
|
|
|
+ reset()
|
|
|
+ return withSession(tmp.path, (registry) =>
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const definitions = yield* toolDefinitions(registry)
|
|
|
+ const definition = definitions.find((tool) => tool.name === "shell")
|
|
|
+ expect(definition?.description).toStartWith("Execute a shell command and return its output.")
|
|
|
+ expect(definition?.inputSchema).not.toHaveProperty("properties.timeout.maximum")
|
|
|
+ // Code Mode receives the declared output schema, including the command output text.
|
|
|
+ expect(definition?.outputSchema).toHaveProperty("properties.output")
|
|
|
+ expect(
|
|
|
+ (yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
|
|
|
+ (tool) => tool.name,
|
|
|
+ ),
|
|
|
+ ).not.toContain("shell")
|
|
|
+
|
|
|
+ const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
|
|
+ expect(settled.status).toBe("completed")
|
|
|
+ expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
|
|
+ expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
|
|
+ expect(settled.content?.[1]).toMatchObject({
|
|
|
+ type: "text",
|
|
|
+ text: expect.stringContaining("Command exited with code 0."),
|
|
|
+ })
|
|
|
+ expect(assertions).toMatchObject([
|
|
|
+ {
|
|
|
+ sessionID,
|
|
|
+ action: "shell",
|
|
|
+ resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
|
|
|
+ },
|
|
|
+ ])
|
|
|
+ expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ },
|
|
|
+ (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
|
|
+ ),
|
|
|
+ { timeout: 15_000 },
|
|
|
)
|
|
|
|
|
|
it.live("resolves a relative workdir from the active Location", () =>
|
|
|
@@ -577,7 +599,7 @@ describe("ShellTool", () => {
|
|
|
)
|
|
|
|
|
|
it.live(
|
|
|
- "does not repeat shell ID progress",
|
|
|
+ "reports shell ID progress once",
|
|
|
() =>
|
|
|
Effect.acquireUseRelease(
|
|
|
Effect.promise(() => tmpdir()),
|
|
|
@@ -587,7 +609,7 @@ describe("ShellTool", () => {
|
|
|
Effect.gen(function* () {
|
|
|
const updates: Tool.Metadata[] = []
|
|
|
yield* executeTool(registry, {
|
|
|
- ...call({ command: steadyProgressCommand }, "call-steady-progress"),
|
|
|
+ ...call({ command: helloCommand }, "call-shell-id-progress"),
|
|
|
progress: (update) => Effect.sync(() => updates.push(update)),
|
|
|
})
|
|
|
expect(updates).toHaveLength(1)
|