opencode/packages/server/test/session-instances.test.ts

333 lines
16 KiB
TypeScript

import { expect } from "bun:test"
import { App } from "@opencode-ai/core/app"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Form } from "@opencode-ai/core/form"
import { Instance } from "@opencode-ai/core/instance"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Permission } from "@opencode-ai/core/permission"
import { Plugin } from "@opencode-ai/core/plugin"
import { Session } from "@opencode-ai/core/session"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Global } from "@opencode-ai/util/global"
import { Context, Duration, Effect, Layer, LayerMap, RcMap, Schema } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { LanguageModel, LLMClient } from "../../ai/src"
import { OpenAIChat } from "../../ai/src/protocols/openai-chat"
import { TestLLM } from "../../ai/src/testing"
import { tempGlobalLayer } from "../../core/test/fixture/global"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createEmbeddedRoutes } from "../src/routes"
it.live(
"isolates same-directory Session tools, hooks, commands, and HTTP requests through one selector",
() =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
const location = Location.Ref.make({ directory: AbsolutePath.make(directory.path) })
const first = { id: Session.ID.make("ses_instance_first"), tool: "instance_first", temperature: 0.1 }
const second = { id: Session.ID.make("ses_instance_second"), tool: "instance_second", temperature: 0.2 }
const configs = [first, second]
const boots: Session.ID[] = []
const executed: Session.ID[] = []
const commands: Session.ID[] = []
const llm = yield* TestLLM.Test.pipe(Effect.provide(TestLLM.testLayer()))
const model = SessionRunnerModel.resolved(
LanguageModel.make({ id: "instance-model", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 8_192 },
},
)
// Host and private instances must reuse the same global layer identities.
const replacements: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
Database.node.replace(Database.node),
Bus.node.replace(Bus.node),
App.node.replace(App.node),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
llmClient.replace(Layer.succeed(LLMClient.Service, llm)),
SessionRunnerModel.node.replace(
Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed(model) }),
),
Instance.node.replace(
makeGlobalNode({
service: Instance.Service,
deps: [LocationServiceMap.node],
layer: Layer.effect(
Instance.Service,
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const instances = yield* LayerMap.make(
(id: Session.ID) => {
const config = configs.find((config) => config.id === id)
if (!config) throw new Error(`No instance configuration for ${id}`)
return Instance.layer(location, {
discovery: false,
replacements: bindings,
plugins: [
define({
id: "session-instance",
effect: Effect.fnUntraced(function* (ctx) {
boots.push(config.id)
yield* ctx.agent.transform((editor) =>
editor.update("build", (agent) => {
agent.permissions = [{ action: "*", resource: "*", effect: "allow" }]
}),
)
yield* ctx.session.hook("prompt", (event) =>
Effect.sync(() => {
event.prompt.text += ` [${config.tool}]`
}),
)
yield* ctx.session.hook("context", (event) =>
Effect.sync(() => {
event.generation.temperature = config.temperature
}),
)
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => {
event.effect = event.action === "instance-test" ? "ask" : "allow"
if (event.action === "instance-test") event.message = config.tool
}),
)
yield* ctx.tool.transform((editor) =>
editor.add({
name: config.tool,
description: `Tool for ${config.id}`,
input: Schema.Struct({}),
output: Schema.String,
options: { codemode: false },
execute: () =>
Effect.sync(() => {
executed.push(config.id)
return { output: config.id, content: config.id }
}),
}),
)
yield* ctx.command.transform((editor) =>
editor.add({
name: "instance-check",
execute: (input) =>
ctx.session
.prompt({
sessionID: input.sessionID,
text: `command ${config.tool}`,
delivery: input.delivery,
resume: false,
})
.pipe(
Effect.tap(() => Effect.sync(() => commands.push(config.id))),
Effect.asVoid,
),
}),
)
}),
}),
],
})
},
{ idleTimeToLive: Duration.infinity },
)
const selector = Instance.Service.of({
provide: (session) => Effect.provide(instances.get(session.id)),
})
const bindings: LayerNode.Replacements = [
...replacements,
Instance.node.replace(Layer.succeed(Instance.Service, selector)),
LocationServiceMap.node.replace(Layer.succeed(LocationServiceMap.Service, locations)),
]
return selector
}),
),
}),
),
]
const context = yield* Layer.build(
createEmbeddedRoutes({}, replacements).pipe(Layer.provide(HttpServer.layerServices)),
)
const sessions = Context.get(context, Session.Service)
const instances = Context.get(context, Instance.Service)
const locations = Context.get(context, LocationServiceMap.Service)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const request = (route: string, body?: unknown) =>
Effect.promise(() =>
handler(
new Request(
`http://opencode.local${route}`,
body === undefined
? undefined
: { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) },
),
),
)
yield* Effect.forEach(configs, (config) =>
sessions.create({
id: config.id,
title: config.tool,
agent: Agent.ID.make("build"),
model: model.ref,
location,
}),
)
expect((yield* sessions.list()).data.map((session) => session.location)).toEqual([location, location])
expect(yield* sessions.messages({ sessionID: first.id })).toEqual([])
yield* sessions.get(second.id)
for (const config of configs) {
for (const resource of ["permission", "form"]) {
const response = yield* request(`/api/session/${config.id}/${resource}`)
expect(response.status).toBe(200)
expect(yield* Effect.promise<unknown>(() => response.json())).toEqual({ data: [] })
}
}
// Reading permissions or forms builds the Session's Instance and starts its plugin activation at once; the
// ordering assertion after the prompts is what proves each Session boots exactly once.
for (const config of configs) {
const session = yield* sessions.get(config.id)
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(instances.provide(session))
const admitted = yield* sessions.prompt({ sessionID: config.id, text: "run", resume: false })
expect(admitted.payload.text).toBe(`run [${config.tool}]`)
}
expect(boots).toEqual([first.id, second.id])
for (const config of configs) {
yield* llm.push(TestLLM.tool(`call_${config.tool}`, config.tool, {}), TestLLM.text(config.id, config.tool))
yield* sessions.resume(config.id)
}
expect(executed).toEqual([first.id, second.id])
for (const config of configs) {
yield* llm.push(TestLLM.text(`generated ${config.id}`, config.tool))
expect(yield* sessions.generate({ sessionID: config.id, prompt: "summarize" })).toBe(`generated ${config.id}`)
yield* sessions.command({ sessionID: config.id, command: "instance-check", text: "" })
expect(yield* sessions.inbox(config.id)).toMatchObject([
{ type: "user", payload: { text: `command ${config.tool} [${config.tool}]` } },
])
}
expect(commands).toEqual([first.id, second.id])
expect(
(yield* llm.requests()).map((request) => ({
temperature: request.generation?.temperature,
tools: request.tools?.filter((tool) => tool.name.startsWith("instance_")).map((tool) => tool.name),
})),
).toEqual(
[first, first, second, second, first, second].map((config) => ({
temperature: config.temperature,
tools: [config.tool],
})),
)
// Seed through Core, then use HTTP to reach those exact private instances.
const pending = yield* Effect.forEach(configs, (config) =>
Effect.gen(function* () {
const session = yield* sessions.get(config.id)
return yield* Effect.gen(function* () {
const forms = yield* Form.Service
const permissions = yield* Permission.Service
const form = yield* forms.create({
sessionID: config.id,
title: config.tool,
fields: [{ key: "answer", type: "string" }],
})
const permission = {
id: Permission.ID.create(),
sessionID: config.id,
action: "instance-test",
resources: [config.tool],
}
expect(yield* permissions.ask(permission)).toEqual({ id: permission.id, effect: "ask" })
const foreignID = config.id === first.id ? second.id : first.id
const foreignForm = yield* forms.create({
sessionID: foreignID,
title: "Foreign form",
fields: [{ key: "answer", type: "string" }],
})
const foreignPermission = yield* permissions.ask({
...permission,
id: Permission.ID.create(),
sessionID: foreignID,
})
return {
session,
form,
permission: { ...permission, message: config.tool },
foreignForm,
foreignPermission,
}
}).pipe(instances.provide(session))
}),
)
for (const entry of pending) {
const forms = yield* request(`/api/session/${entry.session.id}/form`)
expect(forms.status).toBe(200)
expect(yield* Effect.promise<unknown>(() => forms.json())).toEqual({ data: [entry.form] })
const permissions = yield* request(`/api/session/${entry.session.id}/permission`)
expect(permissions.status).toBe(200)
expect(yield* Effect.promise<unknown>(() => permissions.json())).toEqual({ data: [entry.permission] })
// These IDs exist in the selected instance, but belong to the other Session.
expect((yield* request(`/api/session/${entry.session.id}/form/${entry.foreignForm.id}`)).status).toBe(404)
expect(
(yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}`)).status,
).toBe(404)
expect(
(yield* request(`/api/session/${entry.session.id}/form/${entry.foreignForm.id}/reply`, {
answer: { answer: "wrong" },
})).status,
).toBe(404)
expect(
(yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}/reply`, {
reply: "once",
})).status,
).toBe(404)
yield* Effect.gen(function* () {
const forms = yield* Form.Service
const permissions = yield* Permission.Service
expect(yield* forms.state(entry.foreignForm.id)).toEqual({ status: "pending" })
expect(yield* permissions.get(entry.foreignPermission.id)).toMatchObject({
sessionID: entry.foreignForm.sessionID,
})
}).pipe(instances.provide(entry.session))
}
for (const entry of pending) {
expect(
(yield* request(`/api/session/${entry.session.id}/form/${entry.form.id}/reply`, {
answer: { answer: entry.session.id },
})).status,
).toBe(204)
expect(
(yield* request(`/api/session/${entry.session.id}/permission/${entry.permission.id}/reply`, {
reply: "once",
})).status,
).toBe(204)
yield* Effect.gen(function* () {
const forms = yield* Form.Service
const permissions = yield* Permission.Service
expect(yield* forms.state(entry.form.id)).toEqual({
status: "answered",
answer: { answer: entry.session.id },
})
expect(yield* permissions.get(entry.permission.id)).toBeUndefined()
}).pipe(instances.provide(entry.session))
}
expect(boots).toEqual([first.id, second.id])
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
}),
15_000,
)