diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 0be1345871fd..535238a74944 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -68,12 +68,15 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { // Completions API if (body?.messages) { const last = body.messages[body.messages.length - 1] + // check for sneaky anthropic tool_result + const hasToolResult = + Array.isArray(last?.content) && last.content.some((part: any) => part.type === "tool_result") return { isVision: body.messages.some( (msg: any) => Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), ), - isAgent: last?.role !== "user", + isAgent: hasToolResult || last?.role !== "user", } } @@ -88,7 +91,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { isAgent: last?.role !== "user", } } - } catch {} + } catch { } return { isVision: false, isAgent: false } }) diff --git a/packages/opencode/test/plugin/copilot-initiator.test.ts b/packages/opencode/test/plugin/copilot-initiator.test.ts new file mode 100644 index 000000000000..5133d79311d3 --- /dev/null +++ b/packages/opencode/test/plugin/copilot-initiator.test.ts @@ -0,0 +1,153 @@ +import { test, expect, afterAll } from "bun:test" +import { CopilotAuthPlugin } from "../../src/plugin/copilot" + +// Capture server to verify request headers +let capturedHeaders: Headers | null = null +const server = Bun.serve({ + port: 0, + fetch(req) { + capturedHeaders = req.headers + return new Response(JSON.stringify({ id: "msg_123", content: [], type: "message" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }, +}) + +afterAll(() => { + server.stop() +}) + +async function getWrappedFetch() { + const hooks = await CopilotAuthPlugin({ + client: {} as any, + project: {} as any, + worktree: {} as any, + directory: new URL("file:///tmp") as any, + serverUrl: new URL("http://localhost:4096") as any, + $: Bun.$, + }) + + const auth = await hooks.auth!.loader!( + async () => ({ type: "oauth" as const, refresh: "test-token", access: "test-token", expires: 0 }), + undefined as any, + ) + + return auth.fetch! +} + +test("tool_result in last user message sets x-initiator: agent", async () => { + const wrappedFetch = await getWrappedFetch() + capturedHeaders = null + + const body = JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [ + { role: "user", content: [{ type: "text", text: "Use the tool" }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_123", name: "bash", input: { command: "ls" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_123", content: "file1.txt\nfile2.txt" }], + }, + ], + }) + + await wrappedFetch(`${server.url.origin}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }) + + expect(capturedHeaders).not.toBeNull() + expect(capturedHeaders!.get("x-initiator")).toBe("agent") +}) + +test("mixed text + tool_result sets x-initiator: agent", async () => { + const wrappedFetch = await getWrappedFetch() + capturedHeaders = null + + const body = JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "toolu_123", content: "done" }, + { type: "text", text: "continue please" }, + ], + }, + ], + }) + + await wrappedFetch(`${server.url.origin}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }) + + expect(capturedHeaders).not.toBeNull() + expect(capturedHeaders!.get("x-initiator")).toBe("agent") +}) + +test("normal user text sets x-initiator: user", async () => { + const wrappedFetch = await getWrappedFetch() + capturedHeaders = null + + const body = JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: [{ type: "text", text: "Hello world" }] }], + }) + + await wrappedFetch(`${server.url.origin}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }) + + expect(capturedHeaders).not.toBeNull() + expect(capturedHeaders!.get("x-initiator")).toBe("user") +}) + +test("string content sets x-initiator: user", async () => { + const wrappedFetch = await getWrappedFetch() + capturedHeaders = null + + const body = JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [{ role: "user", content: "Hello world" }], + }) + + await wrappedFetch(`${server.url.origin}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }) + + expect(capturedHeaders).not.toBeNull() + expect(capturedHeaders!.get("x-initiator")).toBe("user") +}) + +test("assistant last message should set x-initiator: agent", async () => { + const wrappedFetch = await getWrappedFetch() + capturedHeaders = null + + const body = JSON.stringify({ + model: "claude-sonnet-4-5-20250929", + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ], + }) + + await wrappedFetch(`${server.url.origin}/v1/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }) + + expect(capturedHeaders).not.toBeNull() + expect(capturedHeaders!.get("x-initiator")).toBe("agent") +})