fix(plugin): enforce user tail after message transforms
Promote assistant-tail prefill repair from model-specific targeting to a transform-level invariant. The plugin now avoids sending assistant-ending histories after message transforms regardless of provider metadata.
This commit is contained in:
@@ -181,7 +181,7 @@ describe("createMessagesTransformHandler", () => {
|
|||||||
await runHandler(hooks, [])
|
await runHandler(hooks, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given a completed assistant response tail #when messages transform runs again #then it does not synthesize a continuation user turn", async () => {
|
it("#given a completed assistant response tail #when messages transform runs again #then it appends a synthetic user recovery turn", async () => {
|
||||||
//#given
|
//#given
|
||||||
const messages: TestMessage[] = [
|
const messages: TestMessage[] = [
|
||||||
{ info: { role: "user" }, parts: [{ type: "text", text: "work on this" }] },
|
{ info: { role: "user" }, parts: [{ type: "text", text: "work on this" }] },
|
||||||
@@ -192,8 +192,13 @@ describe("createMessagesTransformHandler", () => {
|
|||||||
await runHandler(makeHooks({}), messages)
|
await runHandler(makeHooks({}), messages)
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(messages).toHaveLength(2)
|
expect(messages).toHaveLength(3)
|
||||||
expect(messages.at(-1)?.info.role).toBe("assistant")
|
expect(messages.at(-1)?.info.role).toBe("user")
|
||||||
|
expect(messages.at(-1)?.parts[0]).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: "[internal] Continue from the previous assistant state.",
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
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 () => {
|
||||||
@@ -415,7 +420,7 @@ describe("createMessagesTransformHandler", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("#given models that still allow assistant prefill or missing model metadata #when messages transform runs #then it keeps the assistant tail unchanged", async () => {
|
it("#given any provider or missing model metadata ends with an assistant tail #when messages transform runs #then it appends a synthetic user recovery turn", async () => {
|
||||||
//#given
|
//#given
|
||||||
const scenarios: Array<{ name: string; userInfo: TestMessage["info"] }> = [
|
const scenarios: Array<{ name: string; userInfo: TestMessage["info"] }> = [
|
||||||
{
|
{
|
||||||
@@ -455,8 +460,13 @@ describe("createMessagesTransformHandler", () => {
|
|||||||
await runHandler(makeHooks({}), messages)
|
await runHandler(makeHooks({}), messages)
|
||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(messages, scenario.name).toHaveLength(2)
|
expect(messages, scenario.name).toHaveLength(3)
|
||||||
expect(messages.at(-1)?.info.role, scenario.name).toBe("assistant")
|
expect(messages.at(-1)?.info.role, scenario.name).toBe("user")
|
||||||
|
expect(messages.at(-1)?.parts[0], scenario.name).toMatchObject({
|
||||||
|
type: "text",
|
||||||
|
text: "[internal] Continue from the previous assistant state.",
|
||||||
|
synthetic: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
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",
|
|
||||||
])
|
|
||||||
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
|
||||||
@@ -43,17 +32,6 @@ function findLastUserMessage(messages: MessageWithParts[]): UserMessageInfo | un
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function findLastUserTurn(messages: MessageWithParts[]): MessageWithParts | undefined {
|
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
||||||
const message = messages[index]
|
|
||||||
if (message?.info.role === "user") {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null
|
return typeof value === "object" && value !== null
|
||||||
}
|
}
|
||||||
@@ -80,44 +58,6 @@ function readModelIdentifier(info: unknown): ModelIdentifier | undefined {
|
|||||||
return providerID && modelID ? { providerID, modelID } : undefined
|
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 {
|
|
||||||
if (!isRecord(part)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const metadata = part["metadata"]
|
|
||||||
return isRecord(metadata) && metadata["compaction_continue"] === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasInternalContinuationTrigger(messages: MessageWithParts[]): boolean {
|
|
||||||
return findLastUserTurn(messages)?.parts.some(isCompactionContinuationPart) === true
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAssistantPrefillRecoveryMessage(
|
function createAssistantPrefillRecoveryMessage(
|
||||||
lastAssistantMessage: MessageWithParts,
|
lastAssistantMessage: MessageWithParts,
|
||||||
messages: MessageWithParts[],
|
messages: MessageWithParts[],
|
||||||
@@ -160,13 +100,6 @@ function ensureUserTurnAfterAssistantTail(output: MessagesTransformOutput): void
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldRepairAssistantTail = hasInternalContinuationTrigger(output.messages) ||
|
|
||||||
shouldRepairAssistantPrefillForModel(findLastUserModel(output.messages)) ||
|
|
||||||
shouldRepairAssistantPrefillForModel(readModelIdentifier(lastMessage.info))
|
|
||||||
if (!shouldRepairAssistantTail) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
output.messages.push(createAssistantPrefillRecoveryMessage(lastMessage, output.messages))
|
output.messages.push(createAssistantPrefillRecoveryMessage(lastMessage, output.messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user