Skip to content

Commit 906d88d

Browse files
committed
fix(opencode): set x-initiator header for compaction and synthetic messages
1 parent a4a9ea4 commit 906d88d

4 files changed

Lines changed: 722 additions & 5 deletions

File tree

packages/opencode/src/plugin/github-copilot/copilot.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
316316
output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
317317
}
318318

319-
const parts = await sdk.session
319+
const agentName = typeof incoming.agent === "string" ? incoming.agent : (incoming.agent as { name: string }).name
320+
if (agentName === "compaction") {
321+
output.headers["x-initiator"] = "agent"
322+
return
323+
}
324+
325+
const msg = await sdk.session
320326
.message({
321327
path: {
322328
id: incoming.message.sessionID,
@@ -325,11 +331,14 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
325331
query: {
326332
directory: input.directory,
327333
},
328-
throwOnError: true,
329334
})
330335
.catch(() => undefined)
331336

332-
if (parts?.data.parts?.some((part) => part.type === "compaction")) {
337+
const parts = msg?.data?.parts
338+
const synthetic =
339+
!!parts?.length && parts.every((p) => "synthetic" in p && (p as { synthetic?: boolean }).synthetic === true)
340+
341+
if (parts?.some((part) => part.type === "compaction") || synthetic) {
333342
output.headers["x-initiator"] = "agent"
334343
return
335344
}
@@ -342,10 +351,9 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
342351
query: {
343352
directory: input.directory,
344353
},
345-
throwOnError: true,
346354
})
347355
.catch(() => undefined)
348-
if (!session || !session.data.parentID) return
356+
if (!session || !session.data?.parentID) return
349357
// mark subagent sessions as agent initiated matching standard that other copilot tools have
350358
output.headers["x-initiator"] = "agent"
351359
},
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, mock, test } from "bun:test"
2+
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
3+
import { CopilotAuthPlugin } from "../../src/plugin/github-copilot/copilot"
4+
5+
type ChatInput = Parameters<NonNullable<Hooks["chat.headers"]>>[0]
6+
type ChatOutput = Parameters<NonNullable<Hooks["chat.headers"]>>[1]
7+
8+
function model() {
9+
return {
10+
providerID: "github-copilot",
11+
api: {
12+
npm: "@ai-sdk/github-copilot",
13+
},
14+
} as ChatInput["model"]
15+
}
16+
17+
function incoming(input?: Partial<ChatInput>): ChatInput {
18+
return {
19+
sessionID: "session",
20+
agent: "build",
21+
model: model(),
22+
provider: {} as ChatInput["provider"],
23+
message: {
24+
id: "message",
25+
sessionID: "session",
26+
} as ChatInput["message"],
27+
...input,
28+
}
29+
}
30+
31+
async function plugin(input?: { message?: () => Promise<unknown>; session?: () => Promise<unknown> }) {
32+
const client = {
33+
session: {
34+
message: mock(input?.message ?? (() => Promise.resolve({ data: { parts: [] } }))),
35+
get: mock(input?.session ?? (() => Promise.resolve({ data: {} }))),
36+
},
37+
}
38+
39+
const hooks = await CopilotAuthPlugin({
40+
client: client as unknown as PluginInput["client"],
41+
directory: "/tmp",
42+
project: {} as PluginInput["project"],
43+
worktree: "/tmp",
44+
serverUrl: new URL("http://localhost:4096"),
45+
$: Bun.$,
46+
})
47+
48+
return { hooks, client }
49+
}
50+
51+
describe("plugin.github-copilot", () => {
52+
test("marks compaction agent requests as agent initiated", async () => {
53+
const { hooks, client } = await plugin({
54+
message: () => Promise.reject(new Error("should not fetch message")),
55+
})
56+
const output: ChatOutput = { headers: {} }
57+
58+
await hooks["chat.headers"]?.(
59+
incoming({
60+
agent: "compaction",
61+
}),
62+
output,
63+
)
64+
65+
expect(output.headers["x-initiator"]).toBe("agent")
66+
expect(client.session.message).not.toHaveBeenCalled()
67+
expect(client.session.get).not.toHaveBeenCalled()
68+
})
69+
70+
test("marks synthetic-only follow-up messages as agent initiated", async () => {
71+
const { hooks } = await plugin({
72+
message: () =>
73+
Promise.resolve({
74+
data: {
75+
parts: [{ type: "text", synthetic: true }],
76+
},
77+
}),
78+
})
79+
const output: ChatOutput = { headers: {} }
80+
81+
await hooks["chat.headers"]?.(incoming(), output)
82+
83+
expect(output.headers["x-initiator"]).toBe("agent")
84+
})
85+
86+
test("marks auto-compaction marker messages as agent initiated", async () => {
87+
const { hooks } = await plugin({
88+
message: () =>
89+
Promise.resolve({
90+
data: {
91+
parts: [
92+
{
93+
type: "text",
94+
text: "[auto-compaction-followup]",
95+
synthetic: true,
96+
},
97+
{
98+
type: "text",
99+
text: "Continue if you have next steps",
100+
synthetic: true,
101+
},
102+
],
103+
},
104+
}),
105+
})
106+
const output: ChatOutput = { headers: {} }
107+
108+
await hooks["chat.headers"]?.(incoming(), output)
109+
110+
expect(output.headers["x-initiator"]).toBe("agent")
111+
})
112+
113+
test("does not override normal top-level user messages", async () => {
114+
const { hooks } = await plugin({
115+
message: () =>
116+
Promise.resolve({
117+
data: {
118+
parts: [{ type: "text", text: "hello" }],
119+
},
120+
}),
121+
session: () => Promise.resolve({ data: {} }),
122+
})
123+
const output: ChatOutput = { headers: {} }
124+
125+
await hooks["chat.headers"]?.(incoming(), output)
126+
127+
expect(output.headers["x-initiator"]).toBeUndefined()
128+
})
129+
})

0 commit comments

Comments
 (0)