feat(core): tag session http hooks with request kind (#47214)

This commit is contained in:
Aiden Cline 2026-09-03 23:51:26 -05:00 committed by GitHub
parent c907d2ba27
commit 46458f0753
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 157 additions and 15 deletions

View file

@ -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"
}

View file

@ -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"),

View file

@ -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,

View file

@ -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<SessionSchema.Info, "id" | "parentID" | "projectID">, 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 = {

View file

@ -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,

View file

@ -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)] : [],

View file

@ -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" },
}),

View file

@ -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

View file

@ -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,

View file

@ -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<SessionRequestKind> = ["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)),
)
})

View file

@ -30,10 +30,17 @@ export interface SessionContext {
providerOptions: Record<string, unknown>
}
/**
* 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<string, string>
}
@ -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
}

View file

@ -30,10 +30,17 @@ export interface SessionContext {
providerOptions: Record<string, unknown>
}
/**
* 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<string, string>
}
@ -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
}

View file

@ -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(() => {

View file

@ -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) => {