cloudaxe-opencode/packages/opencode/test/session/tools.test.ts

164 lines
5 KiB
TypeScript
Raw Normal View History

import { expect } from "bun:test"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Agent } from "@/agent/agent"
import { MCP } from "@/mcp"
import { Permission } from "@/permission"
import { Provider } from "@/provider/provider"
import { Session } from "@/session/session"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { SessionProcessor } from "@/session/processor"
import { SessionTools } from "@/session/tools"
import { Tool } from "@/tool/tool"
import { ToolRegistry } from "@/tool/registry"
import { Truncate } from "@/tool/truncate"
import { Plugin } from "@/plugin"
import { Effect, Layer, Schema } from "effect"
import { testEffect } from "../lib/effect"
const callID = "call-test"
const sessionID = SessionID.make("ses_test")
const messageID = MessageID.ascending()
const partID = PartID.ascending()
const agent: Agent.Info = {
name: "build",
mode: "primary",
options: {},
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}
const model = {
providerID: ProviderV2.ID.make("test"),
api: { id: "test-model" },
} as Provider.Model
function fakeMcp() {
return MCP.Service.of({
tools: () => Effect.succeed({}),
} as Partial<MCP.Interface> as MCP.Interface)
}
const fakePlugin = Plugin.Service.of({
init: () => Effect.void,
list: () => Effect.succeed([]),
trigger: (_name, _input, output) => Effect.succeed(output),
} satisfies Plugin.Interface)
const fakePermission = Permission.Service.of({
ask: () => Effect.void,
reply: () => Effect.void,
list: () => Effect.succeed([]),
} satisfies Permission.Interface)
const fakeTruncate = Truncate.Service.of({
cleanup: () => Effect.void,
write: () => Effect.succeed("output.txt"),
output: (text: string) => Effect.succeed({ content: text, truncated: false }),
limits: () => Effect.succeed({ maxLines: 2000, maxBytes: 50 * 1024 }),
} satisfies Truncate.Interface)
const layer = Layer.mergeAll(
Layer.succeed(Plugin.Service, fakePlugin),
Layer.succeed(Permission.Service, fakePermission),
Layer.succeed(MCP.Service, fakeMcp()),
Layer.succeed(Truncate.Service, fakeTruncate),
Layer.succeed(
ToolRegistry.Service,
ToolRegistry.Service.of({
ids: () => Effect.succeed(["timing"]),
all: () => Effect.succeed([]),
named: () => Effect.die("unused"),
tools: () =>
Effect.succeed([
{
id: "timing",
description: "updates metadata more than once",
parameters: Schema.Struct({}),
jsonSchema: { type: "object", properties: {} },
execute: (_args, ctx) =>
Effect.gen(function* () {
yield* ctx.metadata({ metadata: { output: "first" } })
yield* ctx.metadata({ metadata: { output: "second" } })
return { title: "timing", metadata: {}, output: "done" }
}),
} satisfies Tool.Def,
]),
}),
),
)
const it = testEffect(layer)
it.effect("preserves running tool start time across metadata updates", () =>
Effect.gen(function* () {
const state: SessionV1.ToolPart = {
id: partID,
sessionID,
messageID,
type: "tool",
tool: "timing",
callID,
state: {
status: "running",
input: {},
time: { start: 100 },
},
}
const updates: number[] = []
const processor = {
message: {
id: messageID,
sessionID,
role: "assistant",
parentID: MessageID.ascending(),
agent: "build",
mode: "build",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ModelV2.ID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
time: { created: 1 },
} satisfies SessionV1.Assistant,
updateToolCall: (_toolCallID, update) =>
Effect.sync(() => {
const next = update(state)
state.state = next.state
if (state.state.status === "running") updates.push(state.state.time.start)
return state
}),
completeToolCall: () => Effect.void,
} satisfies Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
const tools = yield* SessionTools.resolve({
agent,
model,
session: { id: sessionID, permission: [] } as Session.Info,
processor,
bypassAgentCheck: false,
messages: [],
promptOps: {} as never,
})
const execute = tools.timing.execute
if (!execute) throw new Error("timing tool is missing execute")
yield* Effect.promise(() =>
execute(
{},
{
toolCallId: callID,
abortSignal: new AbortController().signal,
},
),
)
expect(updates).toEqual([100, 100])
expect(state.state.status).toBe("running")
if (state.state.status === "running") {
expect(state.state.time.start).toBe(100)
}
}),
)