mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-10 21:54:50 +00:00
feat(core): tag session http hooks with request kind (#47214)
This commit is contained in:
parent
c907d2ba27
commit
46458f0753
14 changed files with 157 additions and 15 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)] : [],
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
80
packages/core/test/session-model-request-hooks.test.ts
Normal file
80
packages/core/test/session-model-request-hooks.test.ts
Normal 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)),
|
||||
)
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue