diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 1f4a38297a8..a7cfab17209 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -5,6 +5,7 @@ import { Credential } from "../../credential" import { Bus } from "../../bus" import { CopilotModels } from "../../github-copilot/models" import { App } from "../../app" +import { Agent } from "../../agent" import { Integration } from "../../integration" import { Model } from "../../model" import { define } from "@opencode-ai/plugin/effect/plugin" @@ -242,6 +243,10 @@ export const GithubCopilotPlugin = define({ yield* ctx.session.hook("http.request", (evt) => Effect.gen(function* () { if (evt.model.providerID !== Provider.ID.githubCopilot) return + if (evt.agent === Agent.ID.make("title")) + evt.request.headers.set("X-Interaction-Type", "conversation-background") + if (evt.agent === Agent.ID.make("compaction")) + evt.request.headers.set("X-Interaction-Type", "conversation-compaction") const token = evt.request.headers.get("x-api-key") if (!token) return const text = yield* Effect.promise(() => evt.request.clone().text()) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index faa709f1cf0..1805bcef49b 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -1,6 +1,7 @@ export * as SessionCompaction from "./compaction" import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest, type LanguageModel } from "@opencode-ai/ai" +import type { StreamOptions } from "@opencode-ai/ai/route" import { SessionError } from "@opencode-ai/schema/session-error" import { Document, type Entry } from "@opencode-ai/schema/config" import { Context, Effect, Layer, Stream } from "effect" @@ -11,14 +12,17 @@ import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import type { SessionMessage } from "./message" import { SessionModelHeaders } from "./model-headers" +import { SessionModelHttp } from "./model-http" import { SessionPromptCacheKey } from "./prompt-cache-key" import { App } from "../app" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { toSessionError } from "./to-session-error" import { Token } from "../util/token" -import type { Info } from "../model" +import type { Info, Ref } from "../model" import { SessionUsage } from "./usage" +import { PluginHooks } from "../plugin/hooks" +import { Agent } from "../agent" const DEFAULT_BUFFER = 20_000 const DEFAULT_KEEP_TOKENS = 15_000 @@ -66,16 +70,18 @@ type Dependencies = { readonly app: App.Info readonly bus: Bus.Interface readonly llm: { - readonly stream: (request: LLMRequest) => Stream.Stream + readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream } readonly models: SessionRunnerModel.Interface readonly config: Settings + readonly hooks: PluginHooks.Interface } export type AutoInput = { readonly session: SessionSchema.Info readonly messages: readonly SessionMessage.Info[] readonly model: LanguageModel + readonly ref: Ref readonly cost: Info["cost"] } @@ -85,9 +91,12 @@ export type ManualInput = { readonly inputID: SessionMessage.ID } +type RequiredInput = Omit + type Plan = { readonly session: SessionSchema.Info readonly model: LanguageModel + readonly ref: Ref readonly cost: Info["cost"] readonly reason: SessionMessage.Compaction["reason"] readonly prompt: string @@ -100,7 +109,7 @@ export type Outcome = | Pick export interface Interface { - readonly required: (input: AutoInput) => boolean + readonly required: (input: RequiredInput) => boolean readonly compact: (input: AutoInput) => Effect.Effect readonly compactManual: (input: ManualInput) => Effect.Effect } @@ -265,6 +274,13 @@ const make = (dependencies: Dependencies) => { messages: [Message.user(plan.prompt)], tools: [], }), + { + http: SessionModelHttp.middleware(dependencies.hooks, { + sessionID: plan.session.id, + agent: Agent.ID.make("compaction"), + model: plan.ref, + }), + }, ) .pipe( Stream.runForEach((event) => { @@ -331,6 +347,7 @@ const make = (dependencies: Dependencies) => { return yield* execute({ session: input.session, model: input.model, + ref: input.ref, cost: input.cost, reason: "auto", ...content, @@ -342,7 +359,7 @@ const make = (dependencies: Dependencies) => { error, }) }) - const required = (input: AutoInput) => { + const required = (input: RequiredInput) => { if (!config.auto) return false const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false @@ -385,6 +402,7 @@ const make = (dependencies: Dependencies) => { return yield* execute({ session: input.session, model: resolved.model, + ref: resolved.ref, cost: resolved.cost, reason: "manual", inputID: input.inputID, @@ -406,12 +424,13 @@ export const layer = Layer.effect( const config = yield* Config.Service const models = yield* SessionRunnerModel.Service const app = yield* App.Metadata - return make({ bus, llm, models, config: settings(yield* config.entries()), app }) + const hooks = yield* PluginHooks.Service + return make({ bus, llm, models, config: settings(yield* config.entries()), app, hooks }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node], + deps: [Bus.node, llmClient, Config.node, SessionRunnerModel.node, App.node, PluginHooks.node], }) diff --git a/packages/core/src/session/model-http.ts b/packages/core/src/session/model-http.ts new file mode 100644 index 00000000000..9bdcf254fcc --- /dev/null +++ b/packages/core/src/session/model-http.ts @@ -0,0 +1,38 @@ +export * as SessionModelHttp from "./model-http" + +import type { StreamOptions } from "@opencode-ai/ai/route" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import { Effect, Stream } from "effect" +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { PluginHooks } from "../plugin/hooks" + +export const middleware = ( + hooks: PluginHooks.Interface, + input: { readonly sessionID: Session.ID; readonly agent: Agent.ID; readonly model: Model.Ref }, +): NonNullable => + (request, handler) => + Effect.gen(function* () { + const before = yield* hooks.trigger("session", "http.request", { + ...input, + request: yield* HttpClientRequest.toWeb(request), + }) + let sent = HttpClientRequest.fromWeb(before.request) + if (before.request.body) + sent = HttpClientRequest.bodyUint8Array( + sent, + new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())), + before.request.headers.get("content-type") ?? undefined, + ) + const response = yield* handler(sent) + const after = yield* hooks.trigger("session", "http.response", { + ...input, + request: before.request, + response: new Response( + [204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream), + { status: response.status, headers: response.headers }, + ), + }) + return HttpClientResponse.fromWeb(sent, after.response) + }).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))) diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index ed0e7ed8659..0f3c5b13a62 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -4,8 +4,7 @@ import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai" import type { StreamOptions } from "@opencode-ai/ai/route" import type { Content } from "@opencode-ai/schema/tool" import { SessionError } from "@opencode-ai/schema/session-error" -import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect" -import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { Cause, Config, Context, Effect, Layer, Result } from "effect" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { App } from "../app" import { Model } from "../model" @@ -15,6 +14,7 @@ import { QuestionTool } from "../tool/plugin/question" import { Tool } from "../tool" import { SessionContext } from "./context" import { SessionModelHeaders } from "./model-headers" +import { SessionModelHttp } from "./model-http" import { SessionPromptCacheKey } from "./prompt-cache-key" import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics" import { MAX_STEPS_PROMPT } from "./runner/max-steps" @@ -227,36 +227,11 @@ export const layer = Layer.effect( toolChoice: stepLimitReached ? "none" : undefined, }) const options: StreamOptions = { - http: (request, handler) => - Effect.gen(function* () { - const before = yield* hooks.trigger("session", "http.request", { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - request: yield* HttpClientRequest.toWeb(request), - }) - let sent = HttpClientRequest.fromWeb(before.request) - if (before.request.body) - sent = HttpClientRequest.bodyUint8Array( - sent, - new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())), - before.request.headers.get("content-type") ?? undefined, - ) - const response = yield* handler(sent) - const after = yield* hooks.trigger("session", "http.response", { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - request: before.request, - response: new Response( - [204, 205, 304].includes(response.status) - ? null - : yield* Stream.toReadableStreamEffect(response.stream), - { status: response.status, headers: response.headers }, - ), - }) - return HttpClientResponse.fromWeb(sent, after.response) - }).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), + http: SessionModelHttp.middleware(hooks, { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + }), } if (promptCacheSnapshots) { const current = PromptCacheDiagnostics.snapshot(request) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index c976e883fe8..66a7223bb1f 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -244,7 +244,7 @@ const layer = Layer.effect( const model = resolved.model // Make room: history must fit the context window before the call. A pending manual // compaction owns this instead; the runner executes it between steps. - const compactionInput = { session, messages: loaded.messages, model, cost: resolved.cost } + const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost } if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { const compacted = yield* compaction.compact(compactionInput) if (compacted.status === "completed") diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 689a02cd0fc..4372403b144 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -1,6 +1,7 @@ export * as SessionTitle from "./title" import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai" +import type { StreamOptions } from "@opencode-ai/ai/route" import { Context, DateTime, Effect, Layer, Stream } from "effect" import { Agent } from "../agent" import { Database } from "../database/database" @@ -9,9 +10,11 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback" import { App } from "../app" import { llmClient } from "../effect/app-node-platform" +import { PluginHooks } from "../plugin/hooks" import { SessionEvent } from "./event" import { SessionHistory } from "./history" import { SessionModelHeaders } from "./model-headers" +import { SessionModelHttp } from "./model-http" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { SessionUsage } from "./usage" @@ -24,11 +27,12 @@ type Dependencies = { readonly app: App.Info readonly bus: Bus.Interface readonly llm: { - readonly stream: (request: LLMRequest) => Stream.Stream + readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream } readonly agents: Agent.Interface readonly models: SessionRunnerModel.Interface readonly store: SessionStore.Interface + readonly hooks: PluginHooks.Interface } export interface Interface { @@ -85,6 +89,13 @@ const make = (dependencies: Dependencies) => { messages: [Message.user(firstUser.text)], tools: [], }), + { + http: SessionModelHttp.middleware(dependencies.hooks, { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + }), + }, ) .pipe( Stream.runForEach((event) => { @@ -135,7 +146,8 @@ export const layer = Layer.effect( const store = yield* SessionStore.Service const database = yield* Database.Service const app = yield* App.Metadata - const title = make({ bus, llm, agents, models, store, app }) + const hooks = yield* PluginHooks.Service + const title = make({ bus, llm, agents, models, store, app, hooks }) return Service.of({ generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID), }) @@ -145,5 +157,14 @@ export const layer = Layer.effect( export const node = makeLocationNode({ service: Service, layer, - deps: [Bus.node, llmClient, Agent.node, SessionRunnerModel.node, SessionStore.node, Database.node, App.node], + deps: [ + Bus.node, + llmClient, + Agent.node, + SessionRunnerModel.node, + SessionStore.node, + Database.node, + App.node, + PluginHooks.node, + ], }) diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index ee01993c2b6..a64ab29b34f 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -141,6 +141,32 @@ describe("GithubCopilotPlugin", () => { }), ) + it.effect("classifies title generation as a background interaction", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { + sessionID: Session.ID.make("ses_title"), + agent: Agent.ID.make("title"), + model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }), + request: new Request("https://api.githubcopilot.com/chat/completions"), + }) + expect(event.request.headers.get("x-interaction-type")).toBe("conversation-background") + }), + ) + + it.effect("classifies compaction requests", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", { + sessionID: Session.ID.make("ses_compaction"), + agent: Agent.ID.make("compaction"), + model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }), + request: new Request("https://api.githubcopilot.com/responses"), + }) + expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction") + }), + ) + it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* Plugin.Service