From 5414c7959a5dfa804b86be25f92fbd3f2f4bf2c8 Mon Sep 17 00:00:00 2001 From: Anas Date: Thu, 15 Jan 2026 20:01:50 +0000 Subject: [PATCH 1/2] fix(opencode): prevent excessive Copilot premium request consumption Fixes the X-Initiator header detection to correctly identify agent-initiated vs user-initiated requests. The previous logic only checked if the last message role was not "user", which failed for synthetic user messages created by message-v2.ts for tool attachments, compactions, and subtasks. New detection strategy: 1. If ANY assistant/tool message exists -> agent (continuation) 2. If multiple user messages exist -> agent (multi-turn) 3. If user message matches synthetic patterns -> agent This ensures only the first real user message consumes a premium request. Fixes #8030 Fixes #8067 --- packages/opencode/src/plugin/copilot.ts | 73 +++++--- packages/opencode/test/plugin/copilot.test.ts | 176 ++++++++++++++++++ 2 files changed, 224 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/test/plugin/copilot.test.ts diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 17ce9debc7d..7f78c17e72c 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -15,6 +15,47 @@ 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 + + const hasNonUser = messages.some((msg: any) => ["assistant", "tool"].includes(msg.role)) + if (hasNonUser) return true + + const users = messages.filter((msg: any) => msg.role === "user") + if (users.length > 1) return true + + return users.some((msg: any) => hasSyntheticContent(msg.content)) +} + +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 +92,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 00000000000..94fc481c87e --- /dev/null +++ b/packages/opencode/test/plugin/copilot.test.ts @@ -0,0 +1,176 @@ +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 + + const hasNonUser = messages.some((msg: any) => ["assistant", "tool"].includes(msg.role)) + if (hasNonUser) return true + + const users = messages.filter((msg: any) => msg.role === "user") + if (users.length > 1) return true + + return users.some((msg: any) => hasSyntheticContent(msg.content)) +} + +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 returns agent", () => { + const body = { + messages: [ + { role: "user", content: "First" }, + { role: "user", content: "Second" }, + ], + } + expect(getInitiator(body)).toBe("agent") + }) + + 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 does not charge premium", () => { + const body = { + messages: [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi" }, + { role: "user", content: "Now do something else" }, + ], + } + expect(getInitiator(body)).toBe("agent") + }) + }) +}) From 7bc7e18600f3b6f41ab95c68817cb9853633c7a3 Mon Sep 17 00:00:00 2001 From: Anas Date: Thu, 15 Jan 2026 22:27:16 +0000 Subject: [PATCH 2/2] fix(copilot): check only last user message for synthetic content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses @bowmanjd feedback - removed the flawed 'multiple user messages' rule. Real user follow-ups now correctly charge premium, matching Copilot CLI behavior. Detection logic: 1. If ANY assistant/tool message exists → agent 2. If LAST user message is synthetic → agent 3. Otherwise → user (charges premium) --- packages/opencode/src/plugin/copilot.ts | 8 +++++--- packages/opencode/test/plugin/copilot.test.ts | 16 ++++++++++------ 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 7f78c17e72c..e5f5ccebe45 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -38,13 +38,15 @@ function hasSyntheticContent(content: unknown): boolean { 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 - const users = messages.filter((msg: any) => msg.role === "user") - if (users.length > 1) 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 users.some((msg: any) => hasSyntheticContent(msg.content)) + return false } function detectVision(messages: any[]): boolean { diff --git a/packages/opencode/test/plugin/copilot.test.ts b/packages/opencode/test/plugin/copilot.test.ts index 94fc481c87e..ca743bc878c 100644 --- a/packages/opencode/test/plugin/copilot.test.ts +++ b/packages/opencode/test/plugin/copilot.test.ts @@ -23,13 +23,15 @@ function hasSyntheticContent(content: unknown): boolean { 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 - const users = messages.filter((msg: any) => msg.role === "user") - if (users.length > 1) 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 users.some((msg: any) => hasSyntheticContent(msg.content)) + return false } function getInitiator(body: any): "user" | "agent" { @@ -98,14 +100,15 @@ describe("plugin.copilot", () => { expect(getInitiator(body)).toBe("agent") }) - test("multiple user messages returns agent", () => { + test("multiple user messages without assistant returns user (each charges)", () => { const body = { messages: [ { role: "user", content: "First" }, { role: "user", content: "Second" }, ], } - expect(getInitiator(body)).toBe("agent") + // Real user follow-ups should charge premium - this is correct Copilot behavior + expect(getInitiator(body)).toBe("user") }) test("synthetic tool attachment returns agent", () => { @@ -162,7 +165,7 @@ describe("plugin.copilot", () => { expect(getInitiator(body)).toBe("agent") }) - test("multi-turn with real user follow-up does not charge premium", () => { + test("multi-turn with real user follow-up correctly detected as agent (assistant exists)", () => { const body = { messages: [ { role: "user", content: "Hello" }, @@ -170,6 +173,7 @@ describe("plugin.copilot", () => { { role: "user", content: "Now do something else" }, ], } + // Agent because assistant message exists, not because of multiple users expect(getInitiator(body)).toBe("agent") }) })