fix(core): classify GitHub Copilot requests on every route (#47160)

This commit is contained in:
Aiden Cline 2026-09-03 22:13:04 -05:00 committed by GitHub
parent c5dca2df37
commit ffac1c5b11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 102 additions and 20 deletions

View file

@ -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<typeof requestMetadata>
function requestMetadata(url: string, body: unknown) {

View file

@ -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<T>(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<typeof fetch>[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")
}),
)