fix(plugin): guard Anthropic assistant prefill tails
Claude Opus 4.7 rejects requests whose final message is an assistant turn, because Anthropic treats that as unsupported assistant prefill. Add a transform guard that appends the existing synthetic user recovery turn only for Anthropic prefill-rejecting model IDs, while preserving assistant-prefill behavior for supported and unknown models. Tests cover Opus 4.7, Opus 4.6 assistant metadata fallback, direct Sonnet 4.6 model fields, allowed OpenAI/Sonnet 4.5 tails, missing metadata, and compaction continuation compatibility. Plan: plans/fix-anthropic-assistant-prefill-tail.md
This commit is contained in:
@@ -19,7 +19,17 @@ type TestPart = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TestMessage = {
|
type TestMessage = {
|
||||||
info: { role: "assistant" | "user" }
|
info: {
|
||||||
|
role: "assistant" | "user"
|
||||||
|
id?: string
|
||||||
|
sessionID?: string
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
system?: string
|
||||||
|
tools?: Record<string, boolean>
|
||||||
|
providerID?: string
|
||||||
|
modelID?: string
|
||||||
|
}
|
||||||
parts: TestPart[]
|
parts: TestPart[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +196,180 @@ describe("createMessagesTransformHandler", () => {
|
|||||||
expect(messages.at(-1)?.info.role).toBe("assistant")
|
expect(messages.at(-1)?.info.role).toBe("assistant")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("#given an Anthropic Opus 4.7 history ends with an ordinary assistant tail #when messages transform runs #then it appends a synthetic user recovery turn", async () => {
|
||||||
|
//#given
|
||||||
|
const messages: TestMessage[] = [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "msg_user",
|
||||||
|
role: "user",
|
||||||
|
sessionID: "ses_opus47_prefill",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||||
|
system: "system-prompt",
|
||||||
|
tools: { bash: true },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "finish the debugging report" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "msg_assistant",
|
||||||
|
role: "assistant",
|
||||||
|
sessionID: "ses_opus47_prefill",
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "## 정리 — 완료" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runHandler(makeHooks({}), messages)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(messages).toHaveLength(3)
|
||||||
|
expect(messages.at(-1)?.info).toMatchObject({
|
||||||
|
role: "user",
|
||||||
|
sessionID: "ses_opus47_prefill",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
|
||||||
|
system: "system-prompt",
|
||||||
|
tools: { bash: true },
|
||||||
|
})
|
||||||
|
expect(messages.at(-1)?.parts[0]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: "[internal] Continue from the previous assistant state.",
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given rejecting model metadata is only on the assistant tail #when messages transform runs #then it appends a synthetic user recovery turn", async () => {
|
||||||
|
//#given
|
||||||
|
const messages: TestMessage[] = [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "msg_user_assistant_model_fallback",
|
||||||
|
role: "user",
|
||||||
|
sessionID: "ses_assistant_model_fallback",
|
||||||
|
agent: "sisyphus",
|
||||||
|
system: "system-prompt",
|
||||||
|
tools: { bash: true },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "continue" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "msg_assistant_model_fallback",
|
||||||
|
role: "assistant",
|
||||||
|
sessionID: "ses_assistant_model_fallback",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "done" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runHandler(makeHooks({}), messages)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(messages).toHaveLength(3)
|
||||||
|
expect(messages.at(-1)?.info).toMatchObject({
|
||||||
|
role: "user",
|
||||||
|
sessionID: "ses_assistant_model_fallback",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "internal", modelID: "assistant-prefill-guard" },
|
||||||
|
system: "system-prompt",
|
||||||
|
tools: { bash: true },
|
||||||
|
})
|
||||||
|
expect(messages.at(-1)?.parts[0]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: "[internal] Continue from the previous assistant state.",
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given rejecting model metadata uses direct provider and model fields #when messages transform runs #then it appends a synthetic user recovery turn", async () => {
|
||||||
|
//#given
|
||||||
|
const messages: TestMessage[] = [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "msg_user_direct_model",
|
||||||
|
role: "user",
|
||||||
|
sessionID: "ses_direct_model",
|
||||||
|
agent: "sisyphus",
|
||||||
|
providerID: "anthropic",
|
||||||
|
modelID: "claude-sonnet-4.6",
|
||||||
|
system: "system-prompt",
|
||||||
|
tools: { bash: true },
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "continue" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "msg_assistant_direct_model",
|
||||||
|
role: "assistant",
|
||||||
|
sessionID: "ses_direct_model",
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "done" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runHandler(makeHooks({}), messages)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(messages).toHaveLength(3)
|
||||||
|
expect(messages.at(-1)?.info).toMatchObject({
|
||||||
|
role: "user",
|
||||||
|
sessionID: "ses_direct_model",
|
||||||
|
agent: "sisyphus",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-sonnet-4.6" },
|
||||||
|
system: "system-prompt",
|
||||||
|
tools: { bash: true },
|
||||||
|
})
|
||||||
|
expect(messages.at(-1)?.parts[0]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: "[internal] Continue from the previous assistant state.",
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("#given models that still allow assistant prefill or missing model metadata #when messages transform runs #then it keeps the assistant tail unchanged", async () => {
|
||||||
|
//#given
|
||||||
|
const scenarios: Array<{ name: string; userInfo: TestMessage["info"] }> = [
|
||||||
|
{
|
||||||
|
name: "openai",
|
||||||
|
userInfo: {
|
||||||
|
role: "user",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5.4" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic allowed",
|
||||||
|
userInfo: {
|
||||||
|
role: "user",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing model",
|
||||||
|
userInfo: { role: "user" },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
const messages: TestMessage[] = [
|
||||||
|
{ info: scenario.userInfo, parts: [{ type: "text", text: scenario.name }] },
|
||||||
|
{ info: { role: "assistant" }, parts: [{ type: "text", text: "completed assistant answer" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runHandler(makeHooks({}), messages)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(messages, scenario.name).toHaveLength(2)
|
||||||
|
expect(messages.at(-1)?.info.role, scenario.name).toBe("assistant")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it("#given an internal compaction continuation reaches an assistant prefill tail #when messages transform runs #then it appends a synthetic user recovery turn", async () => {
|
it("#given an internal compaction continuation reaches an assistant prefill tail #when messages transform runs #then it appends a synthetic user recovery turn", async () => {
|
||||||
//#given
|
//#given
|
||||||
const messages: TestMessage[] = [
|
const messages: TestMessage[] = [
|
||||||
@@ -213,6 +397,36 @@ describe("createMessagesTransformHandler", () => {
|
|||||||
synthetic: true,
|
synthetic: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("#given an allowed model compaction continuation reaches an assistant tail #when messages transform runs #then it still appends a synthetic user recovery turn", async () => {
|
||||||
|
//#given
|
||||||
|
const messages: TestMessage[] = [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
role: "user",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5.4" },
|
||||||
|
},
|
||||||
|
parts: [{
|
||||||
|
type: "text",
|
||||||
|
text: `[session recovered - continuing previous task]\n${OMO_INTERNAL_INITIATOR_MARKER}`,
|
||||||
|
synthetic: true,
|
||||||
|
metadata: { compaction_continue: true },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
{ info: { role: "assistant" }, parts: [{ type: "text", text: "partial assistant tail" }] },
|
||||||
|
]
|
||||||
|
|
||||||
|
//#when
|
||||||
|
await runHandler(makeHooks({}), messages)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(messages.at(-1)?.info).toMatchObject({ role: "user" })
|
||||||
|
expect(messages.at(-1)?.parts[0]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: "[internal] Continue from the previous assistant state.",
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function createRealToolPairValidator(): TransformHook {
|
function createRealToolPairValidator(): TransformHook {
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import type { Message, Part } from "@opencode-ai/sdk"
|
import type { Message, Part } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
import { log } from "../shared/logger"
|
import { log } from "../shared/logger"
|
||||||
|
import { normalizeModelID } from "../shared/model-normalization"
|
||||||
import type { CreatedHooks } from "../create-hooks"
|
import type { CreatedHooks } from "../create-hooks"
|
||||||
|
|
||||||
const ASSISTANT_PREFILL_RECOVERY_TEXT = "[internal] Continue from the previous assistant state."
|
const ASSISTANT_PREFILL_RECOVERY_TEXT = "[internal] Continue from the previous assistant state."
|
||||||
|
const ASSISTANT_PREFILL_UNSUPPORTED_PROVIDERS = new Set([
|
||||||
|
"anthropic",
|
||||||
|
"google-vertex-anthropic",
|
||||||
|
"opencode",
|
||||||
|
])
|
||||||
|
const ASSISTANT_PREFILL_UNSUPPORTED_MODEL_PREFIXES = [
|
||||||
|
"claude-opus-4-7",
|
||||||
|
"claude-opus-4-6",
|
||||||
|
"claude-sonnet-4-6",
|
||||||
|
"claude-mythos",
|
||||||
|
]
|
||||||
|
|
||||||
type MessageWithParts = {
|
type MessageWithParts = {
|
||||||
info: Message
|
info: Message
|
||||||
@@ -12,6 +24,10 @@ type MessageWithParts = {
|
|||||||
|
|
||||||
type MessagesTransformOutput = { messages: MessageWithParts[] }
|
type MessagesTransformOutput = { messages: MessageWithParts[] }
|
||||||
type UserMessageInfo = Extract<Message, { role: "user" }>
|
type UserMessageInfo = Extract<Message, { role: "user" }>
|
||||||
|
type ModelIdentifier = {
|
||||||
|
providerID: string
|
||||||
|
modelID: string
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionID(message: MessageWithParts): string | undefined {
|
function getSessionID(message: MessageWithParts): string | undefined {
|
||||||
return message.info.sessionID
|
return message.info.sessionID
|
||||||
@@ -43,6 +59,53 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return typeof value === "object" && value !== null
|
return typeof value === "object" && value !== null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readStringField(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function readModelIdentifier(info: unknown): ModelIdentifier | undefined {
|
||||||
|
if (!isRecord(info)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = info["model"]
|
||||||
|
const nestedModel = isRecord(model) ? model : undefined
|
||||||
|
const providerID = nestedModel
|
||||||
|
? readStringField(nestedModel, "providerID") ?? readStringField(info, "providerID")
|
||||||
|
: readStringField(info, "providerID")
|
||||||
|
const modelID = nestedModel
|
||||||
|
? readStringField(nestedModel, "modelID") ?? readStringField(info, "modelID")
|
||||||
|
: readStringField(info, "modelID")
|
||||||
|
|
||||||
|
return providerID && modelID ? { providerID, modelID } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLastUserModel(messages: MessageWithParts[]): ModelIdentifier | undefined {
|
||||||
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
|
const message = messages[index]
|
||||||
|
if (message?.info.role === "user") {
|
||||||
|
return readModelIdentifier(message.info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRepairAssistantPrefillForModel(model: ModelIdentifier | undefined): boolean {
|
||||||
|
if (!model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerID = model.providerID.toLowerCase()
|
||||||
|
if (!ASSISTANT_PREFILL_UNSUPPORTED_PROVIDERS.has(providerID)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelID = normalizeModelID(model.modelID.toLowerCase())
|
||||||
|
return ASSISTANT_PREFILL_UNSUPPORTED_MODEL_PREFIXES.some((prefix) => modelID.startsWith(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
function isCompactionContinuationPart(part: unknown): boolean {
|
function isCompactionContinuationPart(part: unknown): boolean {
|
||||||
if (!isRecord(part)) {
|
if (!isRecord(part)) {
|
||||||
return false
|
return false
|
||||||
@@ -63,7 +126,7 @@ function createAssistantPrefillRecoveryMessage(
|
|||||||
const lastUserMessage = findLastUserMessage(messages)
|
const lastUserMessage = findLastUserMessage(messages)
|
||||||
const sessionID = getSessionID(lastAssistantMessage) ?? lastUserMessage?.sessionID ?? ""
|
const sessionID = getSessionID(lastAssistantMessage) ?? lastUserMessage?.sessionID ?? ""
|
||||||
const messageID = `${lastAssistantMessage.info.id}_prefill_recovery`
|
const messageID = `${lastAssistantMessage.info.id}_prefill_recovery`
|
||||||
const model = lastUserMessage?.model ?? {
|
const model = readModelIdentifier(lastUserMessage) ?? {
|
||||||
providerID: "internal",
|
providerID: "internal",
|
||||||
modelID: "assistant-prefill-guard",
|
modelID: "assistant-prefill-guard",
|
||||||
}
|
}
|
||||||
@@ -98,7 +161,9 @@ function ensureUserTurnAfterAssistantTail(output: MessagesTransformOutput): void
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasInternalContinuationTrigger(output.messages)) {
|
const shouldRepairAssistantTail = hasInternalContinuationTrigger(output.messages) ||
|
||||||
|
shouldRepairAssistantPrefillForModel(findLastUserModel(output.messages) ?? readModelIdentifier(lastMessage.info))
|
||||||
|
if (!shouldRepairAssistantTail) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user