From 46458f0753933e623deb828716e9ff6288d21692 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:51:26 -0500 Subject: [PATCH] feat(core): tag session http hooks with request kind (#47214) --- .../src/plugin/provider/github-copilot.ts | 10 +-- packages/core/src/session/compaction.ts | 1 + packages/core/src/session/generate.ts | 1 + packages/core/src/session/model-request.ts | 7 +- packages/core/src/session/runner/llm.ts | 1 + packages/core/src/session/title.ts | 1 + .../core/test/plugin/provider-azure.test.ts | 2 + .../plugin/provider-github-copilot.test.ts | 30 +++++-- .../core/test/plugin/provider-openai.test.ts | 2 + .../test/session-model-request-hooks.test.ts | 80 +++++++++++++++++++ packages/plugin/src/effect/session.ts | 9 +++ packages/plugin/src/promise/session.ts | 9 +++ .../src/docs/content/build/plugins/effect.mdx | 11 ++- .../src/docs/content/build/plugins/index.mdx | 8 +- 14 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 packages/core/test/session-model-request-hooks.test.ts diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index d84d3495193..368b5ea1d3d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,11 +1,11 @@ import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" +import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session" import { Effect, Option, Schema, Semaphore, Stream } from "effect" import { Catalog } from "../../catalog.js" import { Credential } from "../../credential.js" import { Bus } from "../../bus.js" import { CopilotModels } from "../../github-copilot/models.js" import { App } from "../../app.js" -import { Agent } from "../../agent.js" import { Integration } from "../../integration.js" import { Model } from "../../model.js" import { define } from "@opencode-ai/plugin/effect/plugin" @@ -259,7 +259,7 @@ export const GithubCopilotPlugin = define({ const session = yield* ctx.session .get({ sessionID: evt.sessionID }) .pipe(Effect.orElseSucceed(() => undefined)) - const interaction = interactionType(evt.agent, session?.parentID !== undefined) + const interaction = interactionType(evt.kind, session?.parentID !== undefined) evt.headers["X-Interaction-Type"] = interaction if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent" }), @@ -391,9 +391,9 @@ function applyHeaders( // Mirrors the Copilot client's X-Interaction-Type vocabulary: the agent loop is the default, // nested sessions are subagents, and title/compaction are the two utility overrides. -export function interactionType(agent: Agent.ID, child: boolean) { - if (agent === Agent.ID.make("title")) return "conversation-background" - if (agent === Agent.ID.make("compaction")) return "conversation-compaction" +export function interactionType(kind: SessionRequestKind, child: boolean) { + if (kind === "title") return "conversation-background" + if (kind === "compaction") return "conversation-compaction" if (child) return "conversation-subagent" return "conversation-agent" } diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 4b06c492823..48856a004ed 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -396,6 +396,7 @@ export const layer = Layer.effect( messages: history.messages, }) const prepared = yield* input.prepare({ + kind: "compaction", scope: { session: context.session, agentID: Agent.ID.make("compaction"), diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts index d6f7341ed7a..4dada9e8024 100644 --- a/packages/core/src/session/generate.ts +++ b/packages/core/src/session/generate.ts @@ -38,6 +38,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input: messages: history.messages, }) const prepared = yield* context.prepare({ + kind: "generate", scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools }, transcript: { system: transcript.system, diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index ec1cede967a..115aeeec632 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -2,6 +2,7 @@ export * as SessionModelRequest from "./model-request.js" import { HttpOptions, LanguageModel, LLM, LLMRequest, Message, SystemPart } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" +import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session" import type { Agent } from "@opencode-ai/schema/agent" import type { Model } from "@opencode-ai/schema/model" import type { Content } from "@opencode-ai/schema/tool" @@ -59,6 +60,8 @@ export interface Prepared { } interface PrepareInput { + /** Which Session flow issues this request; request hooks receive it alongside the Session identity. */ + readonly kind: SessionRequestKind readonly scope: { readonly session: SessionSchema.Info readonly agentID: Agent.ID @@ -197,6 +200,7 @@ interface HookScope { readonly sessionID: SessionSchema.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly kind: SessionRequestKind } const sessionHeaders = (session: Pick, app: App.Info) => ({ @@ -325,7 +329,7 @@ export const layer = Layer.effect( ) const request = yield* applyModelHooks( hooks, - { sessionID: session.id, agent: input.scope.agentID, model: resolved.ref }, + { sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, kind: input.kind }, LLM.request({ model, http: { @@ -356,6 +360,7 @@ export const layer = Layer.effect( sessionID: session.id, agent: input.scope.agentID, model: resolved.ref, + kind: input.kind, }) : undefined const options: StreamOptions = { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 643dc82b419..eee90fa29e7 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -217,6 +217,7 @@ const layer = Layer.effect( messages: loaded.messages, }) const prepared = yield* context.prepare({ + kind: "session", scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools }, transcript: { system: transcript.system, diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index b8748cc9fa2..7fbbfdcf1ab 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -64,6 +64,7 @@ export const layer = Layer.effect( : Effect.void, ) const prepared = yield* context.prepare({ + kind: "title", scope: { session: input.session, agentID: input.agent.id, model: input.model }, transcript: { system: input.agent.system ? [SystemPart.make(input.agent.system)] : [], diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index dff14d36784..dee5bde2c14 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -281,6 +281,7 @@ describe("AzurePlugin", () => { sessionID: Session.ID.make("ses_azure"), agent: Agent.ID.make("build"), model, + kind: "session", request: new Request("https://test-resource.openai.azure.com/openai/v1/responses", { headers: { "api-key": "stored-token", "x-keep": "yes" }, }), @@ -295,6 +296,7 @@ describe("AzurePlugin", () => { sessionID: Session.ID.make("ses_foundry"), agent: Agent.ID.make("build"), model, + kind: "session", request: new Request("https://test-resource.services.ai.azure.com/anthropic/v1/messages", { headers: { "x-api-key": "stored-token" }, }), diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 2a81f5a12c7..4c5bd4ce826 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -19,6 +19,7 @@ import { } from "@opencode-ai/core/plugin/provider/github-copilot" import { Provider } from "@opencode-ai/core/provider" import { Integration } from "@opencode-ai/core/integration" +import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session" import { fakeSelectorSdk } from "../fixture/selector" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -44,12 +45,13 @@ const sessions = Effect.fn(function* () { return { parent: parent.id, child: child.id } }) -const modelRequest = Effect.fn(function* (sessionID: Session.ID, agent: string) { +const modelRequest = Effect.fn(function* (sessionID: Session.ID, kind: SessionRequestKind, agent = "build") { const hooks = yield* PluginHooks.Service return yield* hooks.trigger("session", "model.request", { sessionID, agent: Agent.ID.make(agent), model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }), + kind, headers: {}, }) }) @@ -154,6 +156,7 @@ describe("GithubCopilotPlugin", () => { sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }), + kind: "session", request: new Request("https://api.githubcopilot.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": "token" }, @@ -171,7 +174,7 @@ describe("GithubCopilotPlugin", () => { it.effect("classifies main-loop steps as agent interactions", () => Effect.gen(function* () { yield* addPlugin() - const event = yield* modelRequest((yield* sessions()).parent, "build") + const event = yield* modelRequest((yield* sessions()).parent, "session") expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" }) }), ) @@ -179,7 +182,7 @@ describe("GithubCopilotPlugin", () => { it.effect("classifies child-session steps as subagent interactions", () => Effect.gen(function* () { yield* addPlugin() - const event = yield* modelRequest((yield* sessions()).child, "build") + const event = yield* modelRequest((yield* sessions()).child, "session") expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" }) }), ) @@ -192,14 +195,22 @@ describe("GithubCopilotPlugin", () => { }), ) - it.effect("classifies compaction requests", () => + it.effect("classifies compaction requests by kind rather than agent", () => Effect.gen(function* () { yield* addPlugin() - const event = yield* modelRequest((yield* sessions()).child, "compaction") + const event = yield* modelRequest((yield* sessions()).child, "compaction", "build") expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" }) }), ) + it.effect("does not classify by agent name", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* modelRequest((yield* sessions()).parent, "session", "compaction") + expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" }) + }), + ) + it.effect("ignores other providers' model requests", () => Effect.gen(function* () { yield* addPlugin() @@ -208,6 +219,7 @@ describe("GithubCopilotPlugin", () => { sessionID: (yield* sessions()).parent, agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5.4") }), + kind: "session", headers: {}, }) expect(event.headers).toEqual({}) @@ -236,6 +248,14 @@ describe("GithubCopilotPlugin", () => { }), ) + it.effect("classifies session generation requests as agent interactions", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* modelRequest((yield* sessions()).parent, "generate") + expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" }) + }), + ) + it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const aisdk = yield* AISDK.Service diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index e8b7def69a1..de35afa7688 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -48,6 +48,7 @@ const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) { sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build"), model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), + kind: "session", baseURL, headers: {}, }) @@ -226,6 +227,7 @@ describe("OpenAIPlugin", () => { const program = Effect.gen(function* () { const requests = yield* SessionModelRequest.Service return yield* requests.prepare({ + kind: "session", scope: { session: Session.Info.make({ id: sessionID, diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts new file mode 100644 index 00000000000..7f20c327d0a --- /dev/null +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -0,0 +1,80 @@ +import { describe, expect } from "bun:test" +import { OpenAIChat } from "@opencode-ai/ai/protocols" +import { Agent } from "@opencode-ai/schema/agent" +import { Money } from "@opencode-ai/schema/money" +import { Session } from "@opencode-ai/schema/session" +import type { SessionRequestKind } from "@opencode-ai/plugin/effect/session" +import { Location } from "@opencode-ai/core/location" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionModelRequest } from "@opencode-ai/core/session/model-request" +import { SessionModelTransport } from "@opencode-ai/core/session/model-transport" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { DateTime, Effect } from "effect" +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { testEffect } from "./lib/effect" +import { PluginTestLayer } from "./plugin/fixture" + +const it = testEffect(PluginTestLayer) + +const KINDS: ReadonlyArray = ["session", "compaction", "title", "generate"] + +const session = Session.Info.make({ + id: Session.ID.make("ses_hook_kind"), + projectID: Project.ID.global, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), +}) +const model = SessionRunnerModel.resolved(OpenAIChat.route.model({ id: "gpt-5.5", provider: "test" }), { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + limit: { context: 200_000, output: 32_000 }, +}) +const transport = SessionModelTransport.Service.of({ + bind: () => ({ execute: () => Effect.die("unused WebSocket execution") }), + close: () => Effect.void, + closeAll: Effect.void, +}) + +describe("SessionModelRequest HTTP hooks", () => { + it.effect("tags every Session request kind on http.request and http.response", () => + Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + const seen: Array<{ hook: string; kind: SessionRequestKind; agent: Agent.ID }> = [] + yield* hooks.register("session", "http.request", (event) => + Effect.sync(() => { + seen.push({ hook: "request", kind: event.kind, agent: event.agent }) + }), + ) + yield* hooks.register("session", "http.response", (event) => + Effect.sync(() => { + seen.push({ hook: "response", kind: event.kind, agent: event.agent }) + }), + ) + const requests = yield* SessionModelRequest.Service.pipe(Effect.provide(SessionModelRequest.layer)) + + for (const kind of KINDS) { + const prepared = yield* requests.prepare({ + kind, + scope: { session, agentID: Agent.ID.make("build"), model }, + transcript: { system: [], messages: [] }, + }) + const http = prepared.options.http + if (!http) throw new Error(`Expected HTTP middleware for ${kind}`) + yield* http(HttpClientRequest.post("https://example.test/v1/chat/completions"), (request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 200 }))), + ) + } + + expect(seen).toEqual( + KINDS.flatMap((kind) => [ + { hook: "request", kind, agent: Agent.ID.make("build") }, + { hook: "response", kind, agent: Agent.ID.make("build") }, + ]), + ) + }).pipe(Effect.provideService(SessionModelTransport.Service, transport)), + ) +}) diff --git a/packages/plugin/src/effect/session.ts b/packages/plugin/src/effect/session.ts index a56c890b695..1400592aa70 100644 --- a/packages/plugin/src/effect/session.ts +++ b/packages/plugin/src/effect/session.ts @@ -30,10 +30,17 @@ export interface SessionContext { providerOptions: Record } +/** + * Why a Session request is being made. Auxiliary requests share the Session's + * hook identity but need to be told apart from the agent loop. + */ +export type SessionRequestKind = "session" | "compaction" | "title" | "generate" + export interface SessionModelRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly kind: SessionRequestKind baseURL?: string headers: Record } @@ -42,6 +49,7 @@ export interface SessionHttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly kind: SessionRequestKind request: Request } @@ -49,6 +57,7 @@ export interface SessionHttpResponse { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly kind: SessionRequestKind readonly request: Request response: Response } diff --git a/packages/plugin/src/promise/session.ts b/packages/plugin/src/promise/session.ts index 11b2ee3703d..bc449dbe68c 100644 --- a/packages/plugin/src/promise/session.ts +++ b/packages/plugin/src/promise/session.ts @@ -30,10 +30,17 @@ export interface SessionContext { providerOptions: Record } +/** + * Why a Session request is being made. Auxiliary requests share the Session's + * hook identity but need to be told apart from the agent loop. + */ +export type SessionRequestKind = "session" | "compaction" | "title" | "generate" + export interface SessionModelRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly kind: SessionRequestKind baseURL?: string headers: Record } @@ -42,6 +49,7 @@ export interface SessionHttpRequest { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly kind: SessionRequestKind request: Request } @@ -49,6 +57,7 @@ export interface SessionHttpResponse { readonly sessionID: Session.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly kind: SessionRequestKind readonly request: Request response: Response } diff --git a/packages/www/src/docs/content/build/plugins/effect.mdx b/packages/www/src/docs/content/build/plugins/effect.mdx index d9fd97a6b9d..44a17d38bf0 100644 --- a/packages/www/src/docs/content/build/plugins/effect.mdx +++ b/packages/www/src/docs/content/build/plugins/effect.mdx @@ -1104,7 +1104,8 @@ effect: (ctx) => }), ``` -Modify model request settings and optionally scope the hook to one provider. +Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind` as +the HTTP hooks below. ```ts effect: (ctx) => @@ -1119,14 +1120,18 @@ effect: (ctx) => ``` Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading -it. +it. Both hooks run for every request a session issues; `event.kind` is `"session"`, `"compaction"`, `"title"`, or +`"generate"` depending on which flow issued it. ```ts effect: (ctx) => Effect.gen(function* () { const session = ctx.session yield* session.hook("http.request", (event) => - Effect.sync(() => event.request.headers.set("x-session-id", event.sessionID)), + Effect.sync(() => { + event.request.headers.set("x-session-id", event.sessionID) + if (event.kind === "title") event.request.headers.set("x-priority", "background") + }), ) yield* session.hook("http.response", (event) => Effect.sync(() => { diff --git a/packages/www/src/docs/content/build/plugins/index.mdx b/packages/www/src/docs/content/build/plugins/index.mdx index 72ea1a230ac..7403c12962f 100644 --- a/packages/www/src/docs/content/build/plugins/index.mdx +++ b/packages/www/src/docs/content/build/plugins/index.mdx @@ -1139,7 +1139,8 @@ Generation options depend on the selected protocol and model: #### Model request -Modify model request settings and optionally scope the hook to one provider. +Modify model request settings and optionally scope the hook to one provider. The event carries the same `kind` +as the HTTP hooks below. ```ts await ctx.session.hook( @@ -1156,9 +1157,14 @@ await ctx.session.hook( Modify native provider requests or responses. Their bodies are one-shot streams; clone or replace a body before reading it. +Both hooks run for every request a session issues. `event.kind` says which flow issued it: `"session"` for the agent +loop, `"compaction"` for checkpoint summaries, `"title"` for title generation, and `"generate"` for transient +`ctx.session.generate` calls. Use it instead of the agent ID to tell auxiliary requests apart. + ```ts await ctx.session.hook("http.request", (event) => { event.request.headers.set("x-session-id", event.sessionID) + if (event.kind === "title") event.request.headers.set("x-priority", "background") }) await ctx.session.hook("http.response", (event) => {