Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/opencode/src/plugin/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,15 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
// 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",
}
}

Expand All @@ -88,7 +91,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
isAgent: last?.role !== "user",
}
}
} catch {}
} catch { }
return { isVision: false, isAgent: false }
})

Expand Down
153 changes: 153 additions & 0 deletions packages/opencode/test/plugin/copilot-initiator.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})