diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 17ce9debc7dd..e5f5ccebe456 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -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 { return { auth: { @@ -51,32 +94,14 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { 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 = { diff --git a/packages/opencode/test/plugin/copilot.test.ts b/packages/opencode/test/plugin/copilot.test.ts new file mode 100644 index 000000000000..ca743bc878cb --- /dev/null +++ b/packages/opencode/test/plugin/copilot.test.ts @@ -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") + }) + }) +})