Skip to content

Commit b18cab0

Browse files
committed
fix(copilot): correct isAgent detection for synthetic messages (PR anomalyco#8721)
Replaces the three-branch Completions/Responses/Messages API detection with a unified detectAgent + detectVision approach using SYNTHETIC_PATTERNS. The old logic set isAgent = (last.role !== 'user'), which missed cases where the last message IS a user message but is synthetic (compaction, tool attachment, subtask injection). This caused those turns to be billed as premium user-initiated requests instead of agent turns. New logic: - Any assistant/tool message in history → agent - Last user message matches SYNTHETIC_PATTERNS → agent - detectVision covers all API formats (image_url, input_image, image, nested tool_result) Also adds test/plugin/copilot.test.ts (adapted from PR, path updated for current structure).
1 parent 8dacd25 commit b18cab0

2 files changed

Lines changed: 176 additions & 47 deletions

File tree

packages/opencode/src/plugin/github-copilot/copilot.ts

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,46 @@ function fix(model: Model): Model {
3737
}
3838
}
3939

40+
41+
const SYNTHETIC_PATTERNS = [
42+
/^Tool \w+ returned an attachment:/,
43+
/^What did we do so far\?/,
44+
/^The following tool was executed by the user$/,
45+
/^Tool result:/i,
46+
/^Tool output:/i,
47+
]
48+
49+
function isSynthetic(text: string): boolean {
50+
if (!text || typeof text !== "string") return false
51+
const trimmed = text.trim()
52+
return SYNTHETIC_PATTERNS.some((p) => p.test(trimmed))
53+
}
54+
55+
function hasSyntheticContent(content: unknown): boolean {
56+
if (typeof content === "string") return isSynthetic(content)
57+
if (!Array.isArray(content)) return false
58+
return content.some((part: any) => isSynthetic(part.text || part.content || ""))
59+
}
60+
61+
function detectAgent(messages: any[]): boolean {
62+
if (!Array.isArray(messages) || messages.length === 0) return false
63+
const hasNonUser = messages.some((msg: any) => ["assistant", "tool"].includes(msg.role))
64+
if (hasNonUser) return true
65+
const last = messages[messages.length - 1]
66+
if (last?.role === "user" && hasSyntheticContent(last.content)) return true
67+
return false
68+
}
69+
70+
function detectVision(messages: any[]): boolean {
71+
return (
72+
messages?.some((msg: any) => {
73+
if (!Array.isArray(msg.content)) return false
74+
return msg.content.some((part: any) => part.type === "image_url" || part.type === "input_image" || part.type === "image" ||
75+
(part.type === "tool_result" && Array.isArray(part.content) && part.content.some((n: any) => n.type === "image")))
76+
}) ?? false
77+
)
78+
}
79+
4080
export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
4181
const sdk = input.client
4282
return {
@@ -79,54 +119,14 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
79119
const { isVision, isAgent } = iife(() => {
80120
try {
81121
const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
82-
83-
// Completions API
84-
if (body?.messages && url.includes("completions")) {
85-
const last = body.messages[body.messages.length - 1]
86-
return {
87-
isVision: body.messages.some(
88-
(msg: any) =>
89-
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
90-
),
91-
isAgent: last?.role !== "user",
92-
}
93-
}
94-
95-
// Responses API
96-
if (body?.input) {
97-
const last = body.input[body.input.length - 1]
98-
return {
99-
isVision: body.input.some(
100-
(item: any) =>
101-
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
102-
),
103-
isAgent: last?.role !== "user",
104-
}
105-
}
106-
107-
// Messages API
108-
if (body?.messages) {
109-
const last = body.messages[body.messages.length - 1]
110-
const hasNonToolCalls =
111-
Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result")
112-
return {
113-
isVision: body.messages.some(
114-
(item: any) =>
115-
Array.isArray(item?.content) &&
116-
item.content.some(
117-
(part: any) =>
118-
part?.type === "image" ||
119-
// images can be nested inside tool_result content
120-
(part?.type === "tool_result" &&
121-
Array.isArray(part?.content) &&
122-
part.content.some((nested: any) => nested?.type === "image")),
123-
),
124-
),
125-
isAgent: !(last?.role === "user" && hasNonToolCalls),
126-
}
122+
const messages = body?.messages || body?.input || []
123+
return {
124+
isVision: detectVision(messages),
125+
isAgent: detectAgent(messages),
127126
}
128-
} catch {}
129-
return { isVision: false, isAgent: false }
127+
} catch {
128+
return { isVision: false, isAgent: false }
129+
}
130130
})
131131

132132
const headers: Record<string, string> = {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { describe, expect, test } from "bun:test"
2+
3+
const SYNTHETIC_PATTERNS = [
4+
/^Tool \w+ returned an attachment:/,
5+
/^What did we do so far\?/,
6+
/^The following tool was executed by the user$/,
7+
/^Tool result:/i,
8+
/^Tool output:/i,
9+
]
10+
11+
function isSynthetic(text: string): boolean {
12+
if (!text || typeof text !== "string") return false
13+
const trimmed = text.trim()
14+
return SYNTHETIC_PATTERNS.some((p) => p.test(trimmed))
15+
}
16+
17+
function hasSyntheticContent(content: unknown): boolean {
18+
if (typeof content === "string") return isSynthetic(content)
19+
if (!Array.isArray(content)) return false
20+
return content.some((part: any) => isSynthetic(part.text || part.content || ""))
21+
}
22+
23+
function detectAgent(messages: any[]): boolean {
24+
if (!Array.isArray(messages) || messages.length === 0) return false
25+
const hasNonUser = messages.some((msg: any) => ["assistant", "tool"].includes(msg.role))
26+
if (hasNonUser) return true
27+
const last = messages[messages.length - 1]
28+
if (last?.role === "user" && hasSyntheticContent(last.content)) return true
29+
return false
30+
}
31+
32+
function getInitiator(body: any): "user" | "agent" {
33+
const messages = body?.messages || body?.input || []
34+
return detectAgent(messages) ? "agent" : "user"
35+
}
36+
37+
describe("plugin.copilot", () => {
38+
describe("isSynthetic", () => {
39+
test("detects tool attachment pattern", () => {
40+
expect(isSynthetic("Tool read_file returned an attachment:")).toBe(true)
41+
expect(isSynthetic("Tool bash returned an attachment:")).toBe(true)
42+
})
43+
44+
test("detects compaction pattern", () => {
45+
expect(isSynthetic("What did we do so far?")).toBe(true)
46+
expect(isSynthetic("What did we do so far? ")).toBe(true)
47+
})
48+
49+
test("detects subtask pattern", () => {
50+
expect(isSynthetic("The following tool was executed by the user")).toBe(true)
51+
})
52+
53+
test("ignores normal user messages", () => {
54+
expect(isSynthetic("Hello, can you help me?")).toBe(false)
55+
expect(isSynthetic("Read the file README.md")).toBe(false)
56+
expect(isSynthetic("What did we do yesterday?")).toBe(false)
57+
})
58+
59+
test("handles empty and invalid input", () => {
60+
expect(isSynthetic("")).toBe(false)
61+
expect(isSynthetic(null as any)).toBe(false)
62+
expect(isSynthetic(undefined as any)).toBe(false)
63+
})
64+
})
65+
66+
describe("detectAgent", () => {
67+
test("first user message returns user", () => {
68+
expect(getInitiator({ messages: [{ role: "user", content: "Hello" }] })).toBe("user")
69+
})
70+
71+
test("empty messages returns user", () => {
72+
expect(getInitiator({ messages: [] })).toBe("user")
73+
expect(getInitiator({})).toBe("user")
74+
expect(getInitiator(null)).toBe("user")
75+
})
76+
77+
test("assistant message returns agent", () => {
78+
expect(getInitiator({ messages: [{ role: "user", content: "Hello" }, { role: "assistant", content: "Hi" }] })).toBe("agent")
79+
})
80+
81+
test("tool message returns agent", () => {
82+
expect(getInitiator({ messages: [{ role: "user", content: "Run test" }, { role: "tool", content: "Test passed" }] })).toBe("agent")
83+
})
84+
85+
test("synthetic tool attachment returns agent", () => {
86+
expect(getInitiator({ messages: [{ role: "user", content: "Tool read_file returned an attachment:" }] })).toBe("agent")
87+
})
88+
89+
test("synthetic compaction returns agent", () => {
90+
expect(getInitiator({ messages: [{ role: "user", content: "What did we do so far? " }] })).toBe("agent")
91+
})
92+
93+
test("synthetic with array content returns agent", () => {
94+
expect(getInitiator({
95+
messages: [{
96+
role: "user",
97+
content: [{ type: "text", text: "Tool bash returned an attachment:" }, { type: "file", url: "file://out.txt" }],
98+
}],
99+
})).toBe("agent")
100+
})
101+
102+
test("responses API format works", () => {
103+
expect(getInitiator({ input: [{ role: "user", content: "Hello" }] })).toBe("user")
104+
expect(getInitiator({ input: [{ role: "user", content: "Hello" }, { role: "assistant", content: "Hi" }] })).toBe("agent")
105+
})
106+
})
107+
108+
describe("regression: issues #8030 and #8067", () => {
109+
test("synthetic user message in conversation does not charge premium", () => {
110+
expect(getInitiator({
111+
messages: [
112+
{ role: "user", content: "Read file.txt" },
113+
{ role: "assistant", content: "Reading..." },
114+
{ role: "user", content: "Tool read_file returned an attachment:" },
115+
],
116+
})).toBe("agent")
117+
})
118+
119+
test("multi-turn with real user follow-up still agent (assistant exists)", () => {
120+
expect(getInitiator({
121+
messages: [
122+
{ role: "user", content: "Hello" },
123+
{ role: "assistant", content: "Hi" },
124+
{ role: "user", content: "Now do something else" },
125+
],
126+
})).toBe("agent")
127+
})
128+
})
129+
})

0 commit comments

Comments
 (0)