From ea7fa432437fde76e33a0fa33142df1a9a4aa0c8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 19 Aug 2026 22:33:46 -0400 Subject: [PATCH] refactor(core): share session model requests (#42680) --- .changeset/share-session-model-request.md | 5 + packages/core/src/session/generate-node.ts | 84 ++++--------- packages/core/src/session/model-request.ts | 112 ++++++++---------- packages/core/src/session/runner/llm.ts | 72 +++++++++-- .../core/test/plugin/provider-openai.test.ts | 11 +- packages/core/test/session-generate.test.ts | 13 +- packages/core/test/session-runner.test.ts | 26 +++- 7 files changed, 170 insertions(+), 153 deletions(-) create mode 100644 .changeset/share-session-model-request.md diff --git a/.changeset/share-session-model-request.md b/.changeset/share-session-model-request.md new file mode 100644 index 0000000000..b3333a642b --- /dev/null +++ b/.changeset/share-session-model-request.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Apply shared Session model-request preparation to transient generation. diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index b5fd873b9c..5b6e289dc7 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -1,96 +1,54 @@ export * as SessionGenerateNode from "./generate-node.js" -import { LLM, LLMClient, Message, SystemPart } from "@opencode-ai/ai" +import { LLMClient, Message } from "@opencode-ai/ai" import { Effect, Layer } from "effect" import { Database } from "../database/database.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" -import { App } from "../app.js" import { llmClient } from "../effect/app-node-platform.js" -import { PluginHooks } from "../plugin/hooks.js" import { SessionContext } from "./context.js" import { SessionGenerate } from "./generate.js" import { SessionHistory } from "./history.js" -import { SessionModelHeaders } from "./model-headers.js" -import { SessionModelHook } from "./model-hook.js" -import { SessionModelHttp } from "./model-http.js" -import { SessionPromptCacheKey } from "./prompt-cache-key.js" +import { SessionModelRequest } from "./model-request.js" import { SessionRunnerModel } from "./runner/model.js" -import { SessionSystemPrompt } from "./system-prompt.js" -import { toLLMMessages } from "./runner/to-llm-message.js" export const layer = Layer.effect( SessionGenerate.Service, Effect.gen(function* () { const context = yield* SessionContext.Service const database = yield* Database.Service - const hooks = yield* PluginHooks.Service const llm = yield* LLMClient.Service const models = yield* SessionRunnerModel.Service - const app = yield* App.Metadata + const modelRequests = yield* SessionModelRequest.Service return SessionGenerate.Service.of({ generate: Effect.fn("SessionGenerate.generate")(function* (input) { const selection = yield* context.select(input.sessionID) const model = yield* models.resolve(selection.session) const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions) - const providerMetadataKey = model.model.route.providerMetadataKey ?? model.model.provider - const tools = selection.tools - const toolDefinitions = tools.definitions - const toolsByName = new Map(toolDefinitions.map((tool) => [tool.name, tool])) - const contextEvent = yield* hooks.trigger("session", "context", { - sessionID: selection.session.id, - agent: selection.agent.id, - model: model.ref, - system: [ - selection.agent.info.system - ? selection.agent.info.system - : SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)), - history.initial, - ] - .filter((part) => part.length > 0) - .map(SystemPart.make), - messages: [ - ...toLLMMessages(history.messages, model.ref, providerMetadataKey), - ...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []), - Message.user(input.prompt), - ], - tools: Object.fromEntries( - toolDefinitions.map((tool) => [ - tool.name, - { description: tool.description, input: { ...tool.inputSchema } }, - ]), - ), + const transcript = SessionModelRequest.baseTranscript({ + agent: selection.agent.info, + model, + tools: selection.tools, + initial: history.initial, + messages: history.messages, }) - const hookedTools = Object.entries(contextEvent.tools).flatMap(([name, tool]) => { - const registered = toolsByName.get(name) - return registered - ? [Object.assign({}, registered, { description: tool.description, inputSchema: tool.input })] - : [] + const prepared = yield* modelRequests.prepare({ + scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools }, + transcript: { + system: transcript.system, + messages: [ + ...transcript.messages, + ...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []), + Message.user(input.prompt), + ], + }, }) yield* Effect.logInfo("sending session generation request", { sessionID: selection.session.id, providerID: model.ref.providerID, modelID: model.ref.id, }) - const request = yield* SessionModelHook.apply( - hooks, - { sessionID: selection.session.id, agent: selection.agent.id, model: model.ref }, - LLM.request({ - model: model.model, - http: { headers: SessionModelHeaders.make(selection.session, app) }, - promptCacheKey: SessionPromptCacheKey.make(selection.session.id), - system: contextEvent.system, - messages: contextEvent.messages, - tools: hookedTools, - }), - ) - const response = yield* llm.generate(request, { - http: SessionModelHttp.middleware(hooks, { - sessionID: selection.session.id, - agent: selection.agent.id, - model: model.ref, - }), - }) + const response = yield* llm.generate(prepared.request, prepared.options) yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage }) return response.text }), @@ -101,5 +59,5 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: SessionGenerate.Service, layer, - deps: [SessionContext.node, Database.node, PluginHooks.node, SessionRunnerModel.node, App.node, llmClient], + deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient], }) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 14ebaf6f1b..bd7f5a79e2 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -12,16 +12,17 @@ import { Permission } from "../permission.js" import { PluginHooks } from "../plugin/hooks.js" import { QuestionTool } from "../tool/plugin/question.js" import { Tool } from "../tool.js" -import { SessionContext } from "./context.js" import { SessionModelHeaders } from "./model-headers.js" import { SessionModelHook } from "./model-hook.js" import { SessionModelHttp } from "./model-http.js" import { SessionModelTransport } from "./model-transport.js" import { SessionPromptCacheKey } from "./prompt-cache-key.js" -import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js" -import { MAX_STEPS_PROMPT } from "./runner/max-steps.js" +import { SessionRunnerModel } from "./runner/model.js" +import { SessionSchema } from "./schema.js" import { SessionSystemPrompt } from "./system-prompt.js" import { toLLMMessages } from "./runner/to-llm-message.js" +import type { SessionMessage } from "./message.js" +import type { Agent } from "../agent.js" const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB @@ -48,20 +49,49 @@ const declineDefect = (cause: Cause.Cause) => { interface Prepared { readonly request: LLMRequest readonly options: StreamOptions - /** False when Session HTTP hooks require the request to remain on HTTP. */ - readonly webSocketEligible: boolean /** - * One request-scoped execution operation. Unknown, hook-removed, and - * step-limit-violating calls fail individually through the same seam. + * One request-scoped execution operation. Unknown and hook-removed calls + * fail individually through the same seam. */ readonly executeTool: (input: Parameters[0]) => Effect.Effect - /** True when this request is the final Step; violating calls are rejected and no continuation follows. */ - readonly stepLimitReached: boolean } interface PrepareInput { - readonly context: SessionContext.Loaded - readonly step: number + readonly scope: { + readonly session: SessionSchema.Info + readonly agentID: Agent.ID + readonly model: SessionRunnerModel.Resolved + readonly tools: Tool.Snapshot + } + readonly transcript: { + readonly system: Array + readonly messages: Array + } + readonly toolChoice?: LLM.RequestInput["toolChoice"] + /** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */ + readonly webSocket?: "session" +} + +export const baseTranscript = (input: { + readonly agent: Agent.Info + readonly model: SessionRunnerModel.Resolved + readonly tools: Tool.Snapshot + readonly initial: string + readonly messages: ReadonlyArray +}) => { + const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider + return { + providerMetadataKey, + system: [ + input.agent.system + ? input.agent.system + : SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)), + input.initial, + ] + .filter((part) => part.length > 0) + .map(SystemPart.make), + messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey), + } } const mimeToModality = (mime: string) => { @@ -175,30 +205,11 @@ export const layer = Layer.effect( Config.withDefault(false), Effect.orDie, ) - const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe( - Config.withDefault(false), - Effect.orDie, - ) - const promptCacheSnapshots = diagnostics ? new Map() : undefined - const prepare = Effect.fn("SessionModelRequest.prepare")(function* (input: PrepareInput) { - const session = input.context.session - const agent = input.context.agent - const resolved = input.context.model + const session = input.scope.session + const resolved = input.scope.model const model = resolved.model - const providerMetadataKey = model.route.providerMetadataKey ?? model.provider - const stepLimitReached = agent.info.steps !== undefined && input.step >= agent.info.steps - // The final Step keeps definitions available to protocols with native "none", - // preserving their prompt cache prefix. Calls are still rejected at execution. - const tools = input.context.tools - const system = [ - agent.info.system ? agent.info.system : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)), - input.context.initial, - ] - .filter((part) => part.length > 0) - .map(SystemPart.make) - const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey) - const messages = stepLimitReached ? [...history, Message.assistant(MAX_STEPS_PROMPT)] : history + const tools = input.scope.tools const registry = new Map(tools.definitions.map((tool) => [tool.name, tool])) // The definition objects we hand to hooks, mapped back to their tools. Hooks rename a // tool by moving its definition to a new key; recognizing the object recovers the tool. @@ -210,10 +221,10 @@ export const layer = Layer.effect( // Hooks mutate this record in place: edit descriptions and schemas, rename, or remove. const context = yield* hooks.trigger("session", "context", { sessionID: session.id, - agent: agent.id, + agent: input.scope.agentID, model: resolved.ref, - system, - messages, + system: input.transcript.system, + messages: input.transcript.messages, tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])), }) // Match each surviving entry back to its tool, by recognizing a moved definition or @@ -229,7 +240,7 @@ export const layer = Layer.effect( ) const request = yield* SessionModelHook.apply( hooks, - { sessionID: session.id, agent: agent.id, model: resolved.ref }, + { sessionID: session.id, agent: input.scope.agentID, model: resolved.ref }, LLM.request({ model, http: { @@ -240,7 +251,7 @@ export const layer = Layer.effect( system: context.system, messages: boundImages(unsupportedParts(context.messages, resolved.capabilities)), tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })), - toolChoice: stepLimitReached ? "none" : undefined, + toolChoice: input.toolChoice, }), ) const webSocketEligible = @@ -250,37 +261,20 @@ export const layer = Layer.effect( ? undefined : SessionModelHttp.middleware(hooks, { sessionID: session.id, - agent: agent.id, + agent: input.scope.agentID, model: resolved.ref, }) const options: StreamOptions = { ...(http ? { http } : {}), - ...(webSocket && + ...(input.webSocket === "session" && + webSocket && webSocketEligible && resolved.ref.providerID === Provider.ID.openai && request.model.route.id === "openai-responses" ? { webSocket: transport.bind(session.id) } : {}), } - if (promptCacheSnapshots) { - const current = PromptCacheDiagnostics.snapshot(request) - const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current) - promptCacheSnapshots.delete(session.id) - promptCacheSnapshots.set(session.id, current) - const oldest = promptCacheSnapshots.keys().next().value - if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest) - yield* Effect.logInfo("prompt cache prefix").pipe( - Effect.annotateLogs({ - sessionID: session.id, - toolCount: current.tools.length, - systemParts: current.system.length, - messageCount: current.messages.length, - ...comparison, - }), - ) - } const executeTool: Prepared["executeTool"] = (input) => { - if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" }) const tool = hooked.get(input.call.name) // A registered tool absent from the hooked set was removed or renamed by a hook. if (!tool && registry.has(input.call.name)) @@ -292,9 +286,7 @@ export const layer = Layer.effect( return { request, options, - webSocketEligible, executeTool, - stepLimitReached, } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 1bb22dd930..0e721f4913 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -4,11 +4,12 @@ import { LLMClient, AIError, LLMEvent, + Message, isContextOverflowFailure, type ProviderErrorEvent, type ToolCall, } from "@opencode-ai/ai" -import { Cause, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect" +import { Cause, Config, Data, Effect, Exit, Fiber, FiberSet, Layer, Option, Pull, Schedule, Stream } from "effect" import { Database } from "../../database/database.js" import { Bus } from "../../bus.js" import { Permission } from "../../permission.js" @@ -35,6 +36,9 @@ import { SessionRunnerRetry } from "./retry.js" import { SessionUsage } from "../usage.js" import { ToolOutput } from "../../tool-output.js" import { PluginSupervisor } from "../../plugin/supervisor.js" +import { Tool } from "../../tool.js" +import { PromptCacheDiagnostics } from "../prompt-cache-diagnostics.js" +import { MAX_STEPS_PROMPT } from "./max-steps.js" /** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */ type CallOutcome = Data.TaggedEnum<{ @@ -118,6 +122,32 @@ const layer = Layer.effect( const plugins = yield* PluginSupervisor.Service const title = yield* SessionTitle.Service const toolOutput = yield* ToolOutput.Service + const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe( + Config.withDefault(false), + Effect.orDie, + ) + const promptCacheSnapshots = diagnostics ? new Map() : undefined + const diagnosePromptCache = Effect.fn("SessionRunner.diagnosePromptCache")(function* ( + sessionID: SessionSchema.ID, + request: Parameters[0], + ) { + if (!promptCacheSnapshots) return + const current = PromptCacheDiagnostics.snapshot(request) + const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(sessionID), current) + promptCacheSnapshots.delete(sessionID) + promptCacheSnapshots.set(sessionID, current) + const oldest = promptCacheSnapshots.keys().next().value + if (promptCacheSnapshots.size > 100 && oldest !== undefined) promptCacheSnapshots.delete(oldest) + yield* Effect.logInfo("prompt cache prefix").pipe( + Effect.annotateLogs({ + sessionID, + toolCount: current.tools.length, + systemParts: current.system.length, + messageCount: current.messages.length, + ...comparison, + }), + ) + }) // Title generation starts once input is visible and must not delay model execution. // The in-flight set coalesces overlapping prompts while title presence records success durably. const titlesRunning = new Set() @@ -158,10 +188,7 @@ const layer = Layer.effect( }) /** Work this drain may perform: scoped input, or a between-turn control item next in line. */ - const eligible = Effect.fnUntraced(function* ( - sessionID: SessionSchema.ID, - promotable: SessionInbox.Promotable, - ) { + const eligible = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, promotable: SessionInbox.Promotable) { if (yield* SessionInbox.has(db, sessionID, promotable)) return true if (promotable === "input") return false const next = yield* SessionInbox.nextPromotable(db, sessionID, "input") @@ -298,10 +325,32 @@ const layer = Layer.effect( return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false }) return yield* new StepFailedError({ error: compacted.error }) } - const prepared = yield* modelRequests.prepare({ - context: loaded, - step: currentStep, + const stepLimitReached = agent.info.steps !== undefined && currentStep >= agent.info.steps + const transcript = SessionModelRequest.baseTranscript({ + agent: agent.info, + model: resolved, + tools: loaded.tools, + initial: loaded.initial, + messages: loaded.messages, }) + const prepared = yield* modelRequests.prepare({ + scope: { session, agentID: agent.id, model: resolved, tools: loaded.tools }, + transcript: { + system: transcript.system, + messages: stepLimitReached + ? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)] + : transcript.messages, + }, + // The final Step keeps definitions available to protocols with native "none", + // preserving their prompt cache prefix. Calls are still rejected at execution. + toolChoice: stepLimitReached ? "none" : undefined, + webSocket: "session", + }) + yield* diagnosePromptCache(session.id, prepared.request) + const executeTool = (input: Parameters[0]) => { + if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" }) + return prepared.executeTool(input) + } // Every local tool call forked here is owned until it reaches one durable settlement. const toolRuns: Array<{ readonly call: ToolCall @@ -315,7 +364,7 @@ const layer = Layer.effect( // The selected catalog identity, not model.id: route-level ids are provider API // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). model: resolved.ref, - providerMetadataKey: model.route.providerMetadataKey ?? model.provider, + providerMetadataKey: transcript.providerMetadataKey, snapshot: startSnapshot, assistantMessageID, }) @@ -378,7 +427,7 @@ const layer = Layer.effect( call: event, fiber: yield* Effect.uninterruptibleMask((restore) => restore( - prepared.executeTool({ + executeTool({ sessionID: session.id, agent: agent.id, messageID: assistantMessageID, @@ -526,8 +575,7 @@ const layer = Layer.effect( // A local call or malformed tool input requires another model step, unless // this step already exhausted the agent's allowance. needsContinuation: - !prepared.stepLimitReached && - record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)), + !stepLimitReached && record.calls.some((call) => !call.providerExecuted && (call.called || call.settled)), step: currentStep, }) }), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 687c026177..e1e0130382 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -221,7 +221,6 @@ describe("OpenAIPlugin", () => { }) const sessionID = Session.ID.make("ses_websocket_hooks") const agentID = Agent.ID.make("build") - const agent = Agent.Info.make(Agent.Info.default(agentID)) const model = SessionRunnerModel.resolved(OpenAIResponses.route.model({ id: "gpt-5.5" }), { capabilities: { tools: true, input: ["text"], output: ["text"] }, cost: [], @@ -229,7 +228,7 @@ describe("OpenAIPlugin", () => { const program = Effect.gen(function* () { const requests = yield* SessionModelRequest.Service return yield* requests.prepare({ - context: { + scope: { session: Session.Info.make({ id: sessionID, projectID: Project.ID.global, @@ -238,13 +237,12 @@ describe("OpenAIPlugin", () => { time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), }), - agent: { id: agentID, info: agent }, + agentID, model, - initial: "", - messages: [], tools: { definitions: [], execute: () => Effect.die("unused tool execution") }, }, - step: 1, + transcript: { system: [], messages: [] }, + webSocket: "session", }) }).pipe( Effect.provide(SessionModelRequest.layer), @@ -258,7 +256,6 @@ describe("OpenAIPlugin", () => { const prepared = yield* program - expect(prepared.webSocketEligible).toBe(true) expect(prepared.options.webSocket).toBe(executor) expect(prepared.options.http).toBeUndefined() }), diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 0389f2b45b..fbf6524103 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -9,6 +9,7 @@ import { type LLMRequest, } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" +import type { StreamOptions } from "@opencode-ai/ai/route" import { Agent } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -51,17 +52,17 @@ import { Effect, Layer, Schema, Stream } from "effect" import { testEffect } from "./lib/effect" const requests: LLMRequest[] = [] -let hasHttpMiddleware = false +const options: Array = [] let instruction: string | Instructions.Unavailable = "Initial context" const sessionID = SessionSchema.ID.make("ses_generate_test") const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route }) const client = Layer.mock(LLMClient.Service)({ stream: () => Stream.die(new Error("unused")), - generate: (request, options) => + generate: (request, requestOptions) => Effect.sync(() => { requests.push(request) - hasHttpMiddleware = typeof options?.http === "function" + options.push(requestOptions) const response = LLMResponse.fromEvents([ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "generate" }), @@ -225,7 +226,7 @@ it.effect( () => Effect.gen(function* () { requests.length = 0 - hasHttpMiddleware = false + options.length = 0 instruction = "Initial context" const { db, bus, instructions } = yield* setup yield* InstructionState.prepare(db, bus, instructions, sessionID) @@ -303,6 +304,7 @@ it.effect( modelRequestHook = true }), ) + yield* hooks.register("session", "http.request", () => Effect.void) const generate = yield* SessionGenerate.Service const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" }) @@ -310,7 +312,6 @@ it.effect( expect(result).toBe("Transient answer") expect(requests).toHaveLength(1) expect(modelRequestHook).toBe(true) - expect(hasHttpMiddleware).toBe(true) expect(requests[0]?.model).toBe(model) expect(requests[0]?.system[0]?.text).toBe("Hooked system") expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context") @@ -334,6 +335,8 @@ it.effect( ).toEqual(["Settled partial answer"]) expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }]) expect(requests[0]?.toolChoice).toBeUndefined() + expect(options[0]?.http).toBeFunction() + expect(options[0]?.webSocket).toBeUndefined() expect(yield* durableState(db, sessionID)).toEqual(before) }), { timeout: 15_000 }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 24928dfe72..13b235d5a0 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1037,14 +1037,21 @@ describe("SessionRunnerLLM", () => { const database = yield* Database.Service const bus = yield* Bus.Service yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID) + const loaded = yield* context.load(selected) const prepared = yield* modelRequests.prepare({ - context: yield* context.load(selected), - step: 1, + scope: { + session: loaded.session, + agentID: loaded.agent.id, + model: loaded.model, + tools: loaded.tools, + }, + transcript: { system: [], messages: [] }, + webSocket: "session", }) expect(prepared.request.http?.headers?.["x-model-request-hook"]).toBe("active") - expect(prepared.webSocketEligible).toBe(true) + // No forced HTTP middleware: the other-provider hook must not revoke eligibility. expect(prepared.options.http).toBeUndefined() }), ) @@ -1073,9 +1080,16 @@ describe("SessionRunnerLLM", () => { const database = yield* Database.Service const bus = yield* Bus.Service yield* InstructionState.prepare(database.db, bus, selected.instructions, sessionID) + const loaded = yield* context.load(selected) const prepared = yield* modelRequests.prepare({ - context: yield* context.load(selected), - step: 1, + scope: { + session: loaded.session, + agentID: loaded.agent.id, + model: loaded.model, + tools: loaded.tools, + }, + transcript: { system: [], messages: [] }, + webSocket: "session", }) const http = prepared.options.http ?? (yield* Effect.die("Expected Session HTTP middleware")) @@ -1084,7 +1098,7 @@ describe("SessionRunnerLLM", () => { return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("network"))) }) - expect(prepared.webSocketEligible).toBe(false) + expect(prepared.options.webSocket).toBeUndefined() expect(response.headers["x-response-hook"]).toBe("active") expect(requestTriggers).toBe(1) expect(responseTriggers).toBe(1)