diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index b2e15f9f55f..d84d3495193 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -250,15 +250,26 @@ export const GithubCopilotPlugin = define({ evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) + // Runs for every route, unlike http.request, which the AI SDK route bypasses. + yield* ctx.session.hook( + "model.request", + (evt) => + Effect.gen(function* () { + if (evt.model.providerID !== Provider.ID.githubCopilot) return + const session = yield* ctx.session + .get({ sessionID: evt.sessionID }) + .pipe(Effect.orElseSucceed(() => undefined)) + const interaction = interactionType(evt.agent, session?.parentID !== undefined) + evt.headers["X-Interaction-Type"] = interaction + if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent" + }), + { providerID: Provider.ID.githubCopilot }, + ) 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()) @@ -370,11 +381,23 @@ function applyHeaders( headers.set("User-Agent", App.useragent(app)) headers.set("Openai-Intent", "conversation-edits") headers.set("X-GitHub-Api-Version", apiVersion) - headers.set("x-initiator", metadata.agent ? "agent" : "user") + // The step may already have declared itself agent-initiated (subagent, title, compaction); + // the body can only ever escalate to "agent", never back to "user". + if (metadata.agent) headers.set("x-initiator", "agent") + else if (!headers.has("x-initiator")) headers.set("x-initiator", "user") if (metadata.vision) headers.set("Copilot-Vision-Request", "true") if (anthropic) headers.set("anthropic-beta", "interleaved-thinking-2025-05-14") } +// 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" + if (child) return "conversation-subagent" + return "conversation-agent" +} + type RequestMetadata = ReturnType function requestMetadata(url: string, body: unknown) { diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index 061cd14c010..2a81f5a12c7 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,7 +1,8 @@ import { AISDK } from "@opencode-ai/core/aisdk" import { App } from "@opencode-ai/core/app" import { Agent } from "@opencode-ai/schema/agent" -import { Session } from "@opencode-ai/schema/session" +import { Session } from "@opencode-ai/core/session" +import { Location } from "@opencode-ai/core/location" import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -35,6 +36,24 @@ function required(value: T | undefined): T { return value } +const sessions = Effect.fn(function* () { + const service = yield* Session.Service + const location = yield* Location.Service + const parent = yield* service.create({ location: { directory: location.directory } }) + const child = yield* service.create({ parentID: parent.id }) + return { parent: parent.id, child: child.id } +}) + +const modelRequest = Effect.fn(function* (sessionID: Session.ID, agent: string) { + 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") }), + headers: {}, + }) +}) + describe("GithubCopilotPlugin", () => { test("prefers the account-specific Copilot API endpoint", () => { expect( @@ -149,31 +168,71 @@ describe("GithubCopilotPlugin", () => { }), ) + it.effect("classifies main-loop steps as agent interactions", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* modelRequest((yield* sessions()).parent, "build") + expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-agent" }) + }), + ) + + it.effect("classifies child-session steps as subagent interactions", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* modelRequest((yield* sessions()).child, "build") + expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-subagent", "x-initiator": "agent" }) + }), + ) + it.effect("classifies title generation as a background interaction", () => Effect.gen(function* () { yield* addPlugin() - const hooks = yield* PluginHooks.Service - const event = yield* hooks.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") + const event = yield* modelRequest((yield* sessions()).parent, "title") + expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-background", "x-initiator": "agent" }) }), ) it.effect("classifies compaction requests", () => + Effect.gen(function* () { + yield* addPlugin() + const event = yield* modelRequest((yield* sessions()).child, "compaction") + expect(event.headers).toEqual({ "X-Interaction-Type": "conversation-compaction", "x-initiator": "agent" }) + }), + ) + + it.effect("ignores other providers' model requests", () => Effect.gen(function* () { yield* addPlugin() const hooks = yield* PluginHooks.Service - const event = yield* hooks.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"), + const event = yield* hooks.trigger("session", "model.request", { + 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") }), + headers: {}, }) - expect(event.request.headers.get("x-interaction-type")).toBe("conversation-compaction") + expect(event.headers).toEqual({}) + }), + ) + + it.live("keeps a declared agent initiator when the body looks user-initiated", () => + Effect.gen(function* () { + const requests: Headers[] = [] + const send = copilotFetch( + "token", + async (_input: Parameters[0], init?: RequestInit) => { + requests.push(new Headers(init?.headers)) + return Response.json({ ok: true }) + }, + App.make({ name: "test", version: "1.2.3", channel: "beta" }), + ) + yield* Effect.promise(() => + send("https://api.githubcopilot.com/chat/completions", { + method: "POST", + headers: { "x-initiator": "agent" }, + body: JSON.stringify({ messages: [{ role: "user", content: "summarize" }] }), + }), + ) + expect(requests[0]?.get("x-initiator")).toBe("agent") }), )