Skip to content

Commit a91293e

Browse files
committed
fix: set x-initiator header for compaction and synthetic messages
1 parent ce19c05 commit a91293e

5 files changed

Lines changed: 363 additions & 13 deletions

File tree

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
324324
output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"
325325
}
326326

327-
const parts = await sdk.session
327+
const msg = await sdk.session
328328
.message({
329329
path: {
330330
id: incoming.message.sessionID,
@@ -337,24 +337,28 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
337337
})
338338
.catch(() => undefined)
339339

340-
if (parts?.data.parts?.some((part) => part.type === "compaction")) {
340+
if (
341+
msg?.data?.parts?.some(
342+
(p) => p.type === "compaction" || (p.type === "text" && p.text === "[auto-compaction-followup]"),
343+
)
344+
) {
341345
output.headers["x-initiator"] = "agent"
342346
return
343347
}
344348

345349
const session = await sdk.session
346350
.get({
347351
path: {
348-
id: incoming.sessionID,
352+
id: incoming.message.sessionID,
349353
},
350354
query: {
351355
directory: input.directory,
352356
},
353-
throwOnError: true,
354357
})
355358
.catch(() => undefined)
356-
if (!session || !session.data.parentID) return
357-
// mark subagent sessions as agent initiated matching standard that other copilot tools have
359+
if (!session?.data?.parentID) return
360+
const perms = (session.data as typeof session.data & { permission?: Array<{ permission: string }> }).permission
361+
if (perms?.some((r) => r.permission === "user_slash_command")) return
358362
output.headers["x-initiator"] = "agent"
359363
},
360364
}

packages/opencode/src/session/compaction.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,15 @@ When constructing the summary, try to stick to this template:
318318
agent: userMessage.agent,
319319
model: userMessage.model,
320320
})
321+
yield* session.updatePart({
322+
id: PartID.ascending(),
323+
messageID: continueMsg.id,
324+
sessionID: input.sessionID,
325+
type: "text",
326+
synthetic: true,
327+
ignored: true,
328+
text: "[auto-compaction-followup]",
329+
})
321330
const text =
322331
(input.overflow
323332
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"

packages/opencode/src/tool/task.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ export const TaskTool = Tool.defineEffect(
7070
parentID: ctx.sessionID,
7171
title: params.description + ` (@${next.name} subagent)`,
7272
permission: [
73+
...(params.command
74+
? [
75+
{
76+
permission: "user_slash_command" as const,
77+
pattern: params.command,
78+
action: "allow" as const,
79+
},
80+
]
81+
: []),
7382
...(canTodo
7483
? []
7584
: [
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
import { afterEach, describe, expect, mock, test } from "bun:test"
2+
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
3+
import type { Auth } from "@opencode-ai/sdk"
4+
import { CopilotAuthPlugin } from "../../src/plugin/github-copilot/copilot"
5+
6+
type ChatInput = Parameters<NonNullable<Hooks["chat.headers"]>>[0]
7+
type ChatOutput = Parameters<NonNullable<Hooks["chat.headers"]>>[1]
8+
type Loader = NonNullable<NonNullable<Hooks["auth"]>["loader"]>
9+
10+
const originalFetch = globalThis.fetch
11+
12+
function model() {
13+
return {
14+
providerID: "github-copilot",
15+
api: {
16+
npm: "@ai-sdk/github-copilot",
17+
},
18+
} as ChatInput["model"]
19+
}
20+
21+
function incoming(input?: Partial<ChatInput>): ChatInput {
22+
return {
23+
sessionID: "session",
24+
agent: "build",
25+
model: model(),
26+
provider: {} as ChatInput["provider"],
27+
message: {
28+
id: "message",
29+
sessionID: "session",
30+
} as ChatInput["message"],
31+
...input,
32+
}
33+
}
34+
35+
async function plugin(input?: { message?: () => Promise<unknown>; session?: () => Promise<unknown> }) {
36+
const client = {
37+
session: {
38+
message: mock(input?.message ?? (() => Promise.resolve({ data: { parts: [] } }))),
39+
get: mock(input?.session ?? (() => Promise.resolve({ data: { id: "session" } }))),
40+
},
41+
}
42+
43+
const hooks = await CopilotAuthPlugin({
44+
client: client as unknown as PluginInput["client"],
45+
directory: "/tmp",
46+
project: {} as PluginInput["project"],
47+
worktree: "/tmp",
48+
serverUrl: new URL("http://localhost:4096"),
49+
$: Bun.$,
50+
})
51+
52+
return { hooks, client }
53+
}
54+
55+
function headers(init?: RequestInit) {
56+
if (!init?.headers) return {}
57+
if (init.headers instanceof Headers) return Object.fromEntries(init.headers.entries())
58+
if (Array.isArray(init.headers)) return Object.fromEntries(init.headers)
59+
return init.headers as Record<string, string>
60+
}
61+
62+
async function oauth() {
63+
const { hooks } = await plugin()
64+
const loader = hooks.auth?.loader
65+
if (!loader) throw new Error("Missing auth loader")
66+
return loader(
67+
() =>
68+
Promise.resolve({
69+
type: "oauth",
70+
refresh: "refresh",
71+
access: "access",
72+
expires: 0,
73+
} satisfies Auth),
74+
{} as Parameters<Loader>[1],
75+
)
76+
}
77+
78+
afterEach(() => {
79+
globalThis.fetch = originalFetch
80+
})
81+
82+
describe("plugin.github-copilot", () => {
83+
test("marks compaction messages as agent initiated", async () => {
84+
const { hooks } = await plugin({
85+
message: () =>
86+
Promise.resolve({
87+
data: {
88+
parts: [{ type: "compaction" }],
89+
},
90+
}),
91+
})
92+
const output: ChatOutput = { headers: {} }
93+
94+
await hooks["chat.headers"]?.(
95+
incoming({
96+
agent: "compaction",
97+
}),
98+
output,
99+
)
100+
101+
expect(output.headers["x-initiator"]).toBe("agent")
102+
})
103+
104+
test("does not mark synthetic-only messages without compaction marker as agent initiated", async () => {
105+
const { hooks } = await plugin({
106+
message: () =>
107+
Promise.resolve({
108+
data: {
109+
parts: [
110+
{
111+
type: "text",
112+
synthetic: true,
113+
text: "Summarize the task tool output above and continue with your task.",
114+
},
115+
],
116+
},
117+
}),
118+
})
119+
const output: ChatOutput = { headers: {} }
120+
121+
await hooks["chat.headers"]?.(incoming(), output)
122+
123+
expect(output.headers["x-initiator"]).toBeUndefined()
124+
})
125+
126+
test("marks auto-compaction marker messages as agent initiated", async () => {
127+
const { hooks } = await plugin({
128+
message: () =>
129+
Promise.resolve({
130+
data: {
131+
parts: [
132+
{
133+
type: "text",
134+
text: "[auto-compaction-followup]",
135+
synthetic: true,
136+
ignored: true,
137+
},
138+
{
139+
type: "text",
140+
text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.",
141+
synthetic: true,
142+
},
143+
],
144+
},
145+
}),
146+
})
147+
const output: ChatOutput = { headers: {} }
148+
149+
await hooks["chat.headers"]?.(incoming(), output)
150+
151+
expect(output.headers["x-initiator"]).toBe("agent")
152+
})
153+
154+
test("marks compaction trigger messages as agent initiated", async () => {
155+
const { hooks } = await plugin({
156+
message: () =>
157+
Promise.resolve({
158+
data: {
159+
parts: [
160+
{
161+
type: "compaction",
162+
summary: "The conversation was compacted.",
163+
auto: true,
164+
},
165+
],
166+
},
167+
}),
168+
})
169+
const output: ChatOutput = { headers: {} }
170+
171+
await hooks["chat.headers"]?.(incoming(), output)
172+
173+
expect(output.headers["x-initiator"]).toBe("agent")
174+
})
175+
176+
test("marks AI-initiated child session as agent initiated", async () => {
177+
const { hooks } = await plugin({
178+
message: () =>
179+
Promise.resolve({
180+
data: {
181+
parts: [{ type: "text", text: "Review the code changes" }],
182+
},
183+
}),
184+
session: () =>
185+
Promise.resolve({
186+
data: {
187+
id: "child-session",
188+
parentID: "parent-session",
189+
permission: [],
190+
},
191+
}),
192+
})
193+
const output: ChatOutput = { headers: {} }
194+
195+
await hooks["chat.headers"]?.(
196+
incoming({
197+
sessionID: "child-session",
198+
message: { id: "message", sessionID: "child-session" } as ChatInput["message"],
199+
}),
200+
output,
201+
)
202+
203+
expect(output.headers["x-initiator"]).toBe("agent")
204+
})
205+
206+
test("does not mark user slash command child session as agent initiated", async () => {
207+
const { hooks } = await plugin({
208+
message: () =>
209+
Promise.resolve({
210+
data: {
211+
parts: [{ type: "text", text: "Review the code changes" }],
212+
},
213+
}),
214+
session: () =>
215+
Promise.resolve({
216+
data: {
217+
id: "child-session",
218+
parentID: "parent-session",
219+
permission: [{ permission: "user_slash_command", pattern: "review", action: "allow" }],
220+
},
221+
}),
222+
})
223+
const output: ChatOutput = { headers: {} }
224+
225+
await hooks["chat.headers"]?.(
226+
incoming({
227+
sessionID: "child-session",
228+
message: { id: "message", sessionID: "child-session" } as ChatInput["message"],
229+
}),
230+
output,
231+
)
232+
233+
expect(output.headers["x-initiator"]).toBeUndefined()
234+
})
235+
236+
test("does not override normal top-level user messages", async () => {
237+
const { hooks } = await plugin({
238+
message: () =>
239+
Promise.resolve({
240+
data: {
241+
parts: [{ type: "text", text: "hello" }],
242+
},
243+
}),
244+
})
245+
const output: ChatOutput = { headers: {} }
246+
247+
await hooks["chat.headers"]?.(incoming(), output)
248+
249+
expect(output.headers["x-initiator"]).toBeUndefined()
250+
})
251+
252+
test("sets the vision header for normal image requests", async () => {
253+
let hdrs = {}
254+
globalThis.fetch = mock((_req: RequestInfo | URL, init?: RequestInit) => {
255+
hdrs = headers(init)
256+
return Promise.resolve(new Response("", { status: 200 }))
257+
}) as unknown as typeof fetch
258+
259+
const cfg = await oauth()
260+
if (typeof cfg.fetch !== "function") throw new Error("Missing auth fetch")
261+
262+
await cfg.fetch("https://api.githubcopilot.com/chat/completions", {
263+
method: "POST",
264+
body: JSON.stringify({
265+
messages: [
266+
{
267+
role: "user",
268+
content: [
269+
{
270+
type: "image_url",
271+
image_url: { url: "https://example.com/image.png" },
272+
},
273+
],
274+
},
275+
],
276+
}),
277+
})
278+
279+
expect(hdrs).toMatchObject({
280+
"x-initiator": "user",
281+
"Copilot-Vision-Request": "true",
282+
})
283+
})
284+
285+
test("keeps the vision header when x-initiator is already agent", async () => {
286+
let hdrs = {}
287+
globalThis.fetch = mock((_req: RequestInfo | URL, init?: RequestInit) => {
288+
hdrs = headers(init)
289+
return Promise.resolve(new Response("", { status: 200 }))
290+
}) as unknown as typeof fetch
291+
292+
const cfg = await oauth()
293+
if (typeof cfg.fetch !== "function") throw new Error("Missing auth fetch")
294+
295+
await cfg.fetch("https://api.githubcopilot.com/chat/completions", {
296+
method: "POST",
297+
headers: {
298+
"x-initiator": "agent",
299+
},
300+
body: JSON.stringify({
301+
messages: [
302+
{
303+
role: "user",
304+
content: [
305+
{
306+
type: "image_url",
307+
image_url: { url: "https://example.com/image.png" },
308+
},
309+
],
310+
},
311+
],
312+
}),
313+
})
314+
315+
expect(hdrs).toMatchObject({
316+
"x-initiator": "agent",
317+
"Copilot-Vision-Request": "true",
318+
})
319+
})
320+
})

0 commit comments

Comments
 (0)