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 = {
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -186,6 +196,180 @@ describe("createMessagesTransformHandler", () => {
|
||||
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 () => {
|
||||
//#given
|
||||
const messages: TestMessage[] = [
|
||||
@@ -213,6 +397,36 @@ describe("createMessagesTransformHandler", () => {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user