feat(plugin): wire session request hook

This commit is contained in:
Aiden Cline 2026-07-17 14:32:08 -05:00 committed by 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
commit 7edaa05869
17 changed files with 447 additions and 73 deletions

View file

@ -3,12 +3,17 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { LLMClient, RequestExecutor, type RequestData } from "@opencode-ai/ai/route"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const it = testEffect(AISDK.locationLayer)
const it = testEffect(
Layer.merge(
AISDK.locationLayer,
LLMClient.layer.pipe(Layer.provide(Layer.mock(RequestExecutor.Service)({ execute: () => Effect.die("unused") }))),
),
)
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
ModelV2.Info.make({
@ -99,6 +104,64 @@ it.effect("projects request settings, headers, and body overlays", () =>
}),
)
it.effect("isolates request transforms across cached AI SDK streams", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
const upstream: Array<{ headers: Headers; body: Record<string, unknown> }> = []
let sdkFetch: typeof fetch | undefined
const input = model("test-sdk")
if (!input.settings) return yield* Effect.die("AI SDK test model settings are missing")
Object.assign(input.settings, {
fetch: async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
upstream.push({
headers: new Headers(init?.headers),
body: JSON.parse(String(init?.body)),
})
return new Response()
},
})
yield* aisdk.hook.sdk((event) => {
sdkFetch = event.options.fetch
event.sdk = {
languageModel: () => ({
doStream: async (options: LanguageModelV3CallOptions) => {
if (!sdkFetch) throw new Error("AI SDK fetch was not installed")
await sdkFetch("https://provider.test/model", {
method: "POST",
headers: Object.fromEntries(
Object.entries(options.headers ?? {}).filter(
(entry): entry is [string, string] => entry[1] !== undefined,
),
),
body: JSON.stringify({ model: "api-model", remove: true }),
})
return { stream: new ReadableStream({ start: (controller) => controller.close() }) }
},
}),
}
})
const resolved = yield* aisdk.model(input)
const llm = yield* LLMClient.Service
const run = (session: string) =>
llm
.stream(LLM.request({ model: resolved, prompt: session }), {
transformRequest: (request) =>
Effect.sync(() => {
const body: RequestData["body"] = { ...request.body, session }
delete body.remove
return { headers: { ...request.headers, "x-session": session }, body }
}),
})
.pipe(Stream.runDrain)
yield* Effect.all([run("one"), run("two")], { concurrency: "unbounded" })
expect(upstream.map((request) => request.headers.get("x-session")).toSorted()).toEqual(["one", "two"])
expect(upstream.map((request) => request.body.session).toSorted()).toEqual(["one", "two"])
expect(upstream.every((request) => !("remove" in request.body))).toBe(true)
}),
)
it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service

View file

@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -181,6 +182,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
agent: AgentV2.ID.make("build"),
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),

View file

@ -0,0 +1,73 @@
import { expect } from "bun:test"
import { LLM, LLMClient } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import type { RequestData } from "@opencode-ai/ai/route"
import { Agent } from "@opencode-ai/schema/agent"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Layer, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { SessionModelStream } from "@opencode-ai/core/session/model-stream"
import { testEffect } from "./lib/effect"
const prepared: RequestData[] = []
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
generate: () => Effect.die("unused"),
stream: (_request, options) =>
Stream.unwrap(
options?.transformRequest
? options.transformRequest({ headers: { original: "true" }, body: { remove: true } }).pipe(
Effect.map((request) => {
prepared.push(request)
return Stream.empty
}),
)
: Effect.die("request transform was not provided"),
),
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([PluginHooks.node, SessionModelStream.node]), [[llmClient, client]]),
)
it.effect("forwards session identity and applies request hook mutations", () =>
Effect.gen(function* () {
prepared.length = 0
const hooks = yield* PluginHooks.Service
const stream = yield* SessionModelStream.Service
const sessionID = Session.ID.make("ses_model_stream")
const agent = Agent.ID.make("build")
const model = Model.Ref.make({
providerID: Provider.ID.make("test"),
id: Model.ID.make("catalog-model"),
})
yield* hooks.register("session", "request", (event) =>
Effect.sync(() => {
expect(event.sessionID).toBe(sessionID)
expect(event.agent).toBe(agent)
expect(event.model).toEqual(model)
event.headers["x-plugin"] = "enabled"
delete event.body.remove
}),
)
yield* stream
.stream({
sessionID,
agent,
model,
request: LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" } })
.model({ id: "api-model" }),
prompt: "Hello",
}),
})
.pipe(Stream.runDrain)
expect(prepared).toEqual([{ headers: { original: "true", "x-plugin": "enabled" }, body: {} }])
}),
)