Skip to content
Open
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
75 changes: 50 additions & 25 deletions packages/opencode/src/plugin/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,49 @@ function getUrls(domain: string) {
}
}

const SYNTHETIC_PATTERNS = [
/^Tool \w+ returned an attachment:/,
/^What did we do so far\?/,
/^The following tool was executed by the user$/,
/^Tool result:/i,
/^Tool output:/i,
]

function isSynthetic(text: string): boolean {
if (!text || typeof text !== "string") return false
const trimmed = text.trim()
return SYNTHETIC_PATTERNS.some((p) => p.test(trimmed))
}

function hasSyntheticContent(content: unknown): boolean {
if (typeof content === "string") return isSynthetic(content)
if (!Array.isArray(content)) return false
return content.some((part: any) => isSynthetic(part.text || part.content || ""))
}

function detectAgent(messages: any[]): boolean {
if (!Array.isArray(messages) || messages.length === 0) return false

// Rule 1: If any assistant/tool message exists, this is a continuation
const hasNonUser = messages.some((msg: any) => ["assistant", "tool"].includes(msg.role))
if (hasNonUser) return true

// Rule 2: Check if the LAST user message is synthetic (compaction, tool result, etc.)
const last = messages[messages.length - 1]
if (last?.role === "user" && hasSyntheticContent(last.content)) return true

return false
}

function detectVision(messages: any[]): boolean {
return (
messages?.some((msg: any) => {
if (!Array.isArray(msg.content)) return false
return msg.content.some((part: any) => part.type === "image_url" || part.type === "input_image")
}) ?? false
)
}

export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
return {
auth: {
Expand Down Expand Up @@ -51,32 +94,14 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const { isVision, isAgent } = iife(() => {
try {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body

// Completions API
if (body?.messages) {
const last = body.messages[body.messages.length - 1]
return {
isVision: body.messages.some(
(msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
),
isAgent: last?.role !== "user",
}
}

// Responses API
if (body?.input) {
const last = body.input[body.input.length - 1]
return {
isVision: body.input.some(
(item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
),
isAgent: last?.role !== "user",
}
const messages = body?.messages || body?.input || []
return {
isVision: detectVision(messages),
isAgent: detectAgent(messages),
}
} catch {}
return { isVision: false, isAgent: false }
} catch {
return { isVision: false, isAgent: false }
}
})

const headers: Record<string, string> = {
Expand Down
180 changes: 180 additions & 0 deletions packages/opencode/test/plugin/copilot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, expect, test } from "bun:test"

const SYNTHETIC_PATTERNS = [
/^Tool \w+ returned an attachment:/,
/^What did we do so far\?/,
/^The following tool was executed by the user$/,
/^Tool result:/i,
/^Tool output:/i,
]

function isSynthetic(text: string): boolean {
if (!text || typeof text !== "string") return false
const trimmed = text.trim()
return SYNTHETIC_PATTERNS.some((p) => p.test(trimmed))
}

function hasSyntheticContent(content: unknown): boolean {
if (typeof content === "string") return isSynthetic(content)
if (!Array.isArray(content)) return false
return content.some((part: any) => isSynthetic(part.text || part.content || ""))
}

function detectAgent(messages: any[]): boolean {
if (!Array.isArray(messages) || messages.length === 0) return false

// Rule 1: If any assistant/tool message exists, this is a continuation
const hasNonUser = messages.some((msg: any) => ["assistant", "tool"].includes(msg.role))
if (hasNonUser) return true

// Rule 2: Check if the LAST user message is synthetic (compaction, tool result, etc.)
const last = messages[messages.length - 1]
if (last?.role === "user" && hasSyntheticContent(last.content)) return true

return false
}

function getInitiator(body: any): "user" | "agent" {
const messages = body?.messages || body?.input || []
return detectAgent(messages) ? "agent" : "user"
}

describe("plugin.copilot", () => {
describe("isSynthetic", () => {
test("detects tool attachment pattern", () => {
expect(isSynthetic("Tool read_file returned an attachment:")).toBe(true)
expect(isSynthetic("Tool bash returned an attachment:")).toBe(true)
})

test("detects compaction pattern", () => {
expect(isSynthetic("What did we do so far?")).toBe(true)
expect(isSynthetic("What did we do so far? ")).toBe(true)
})

test("detects subtask pattern", () => {
expect(isSynthetic("The following tool was executed by the user")).toBe(true)
})

test("ignores normal user messages", () => {
expect(isSynthetic("Hello, can you help me?")).toBe(false)
expect(isSynthetic("Read the file README.md")).toBe(false)
expect(isSynthetic("What did we do yesterday?")).toBe(false)
})

test("handles empty and invalid input", () => {
expect(isSynthetic("")).toBe(false)
expect(isSynthetic(null as any)).toBe(false)
expect(isSynthetic(undefined as any)).toBe(false)
})
})

describe("detectAgent", () => {
test("first user message returns user", () => {
const body = { messages: [{ role: "user", content: "Hello" }] }
expect(getInitiator(body)).toBe("user")
})

test("empty messages returns user", () => {
expect(getInitiator({ messages: [] })).toBe("user")
expect(getInitiator({})).toBe("user")
expect(getInitiator(null)).toBe("user")
})

test("assistant message returns agent", () => {
const body = {
messages: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi there" },
],
}
expect(getInitiator(body)).toBe("agent")
})

test("tool message returns agent", () => {
const body = {
messages: [
{ role: "user", content: "Run test" },
{ role: "tool", content: "Test passed" },
],
}
expect(getInitiator(body)).toBe("agent")
})

test("multiple user messages without assistant returns user (each charges)", () => {
const body = {
messages: [
{ role: "user", content: "First" },
{ role: "user", content: "Second" },
],
}
// Real user follow-ups should charge premium - this is correct Copilot behavior
expect(getInitiator(body)).toBe("user")
})

test("synthetic tool attachment returns agent", () => {
const body = {
messages: [{ role: "user", content: "Tool read_file returned an attachment:" }],
}
expect(getInitiator(body)).toBe("agent")
})

test("synthetic compaction returns agent", () => {
const body = {
messages: [{ role: "user", content: "What did we do so far? " }],
}
expect(getInitiator(body)).toBe("agent")
})

test("synthetic with array content returns agent", () => {
const body = {
messages: [
{
role: "user",
content: [
{ type: "text", text: "Tool bash returned an attachment:" },
{ type: "file", url: "file://out.txt" },
],
},
],
}
expect(getInitiator(body)).toBe("agent")
})

test("responses API format works", () => {
expect(getInitiator({ input: [{ role: "user", content: "Hello" }] })).toBe("user")
expect(
getInitiator({
input: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi" },
],
}),
).toBe("agent")
})
})

describe("regression: issue #8030 and #8067", () => {
test("synthetic user message after conversation does not charge premium", () => {
const body = {
messages: [
{ role: "user", content: "Read file.txt" },
{ role: "assistant", content: "Reading..." },
{ role: "user", content: "Tool read_file returned an attachment:" },
],
}
expect(getInitiator(body)).toBe("agent")
})

test("multi-turn with real user follow-up correctly detected as agent (assistant exists)", () => {
const body = {
messages: [
{ role: "user", content: "Hello" },
{ role: "assistant", content: "Hi" },
{ role: "user", content: "Now do something else" },
],
}
// Agent because assistant message exists, not because of multiple users
expect(getInitiator(body)).toBe("agent")
})
})
})