From cf2b752537c0b999270d6f2ee0beead478f08cf3 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 19 May 2026 13:12:53 +0900 Subject: [PATCH] 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. --- src/plugin/messages-transform.test.ts | 22 ++++++--- src/plugin/messages-transform.ts | 67 --------------------------- 2 files changed, 16 insertions(+), 73 deletions(-) diff --git a/src/plugin/messages-transform.test.ts b/src/plugin/messages-transform.test.ts index 02f6951dd..8482dd83e 100644 --- a/src/plugin/messages-transform.test.ts +++ b/src/plugin/messages-transform.test.ts @@ -181,7 +181,7 @@ describe("createMessagesTransformHandler", () => { 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 const messages: TestMessage[] = [ { info: { role: "user" }, parts: [{ type: "text", text: "work on this" }] }, @@ -192,8 +192,13 @@ describe("createMessagesTransformHandler", () => { await runHandler(makeHooks({}), messages) //#then - expect(messages).toHaveLength(2) - expect(messages.at(-1)?.info.role).toBe("assistant") + expect(messages).toHaveLength(3) + 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 () => { @@ -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 const scenarios: Array<{ name: string; userInfo: TestMessage["info"] }> = [ { @@ -455,8 +460,13 @@ describe("createMessagesTransformHandler", () => { await runHandler(makeHooks({}), messages) //#then - expect(messages, scenario.name).toHaveLength(2) - expect(messages.at(-1)?.info.role, scenario.name).toBe("assistant") + expect(messages, scenario.name).toHaveLength(3) + 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, + }) } }) diff --git a/src/plugin/messages-transform.ts b/src/plugin/messages-transform.ts index d49d53f44..a92db8402 100644 --- a/src/plugin/messages-transform.ts +++ b/src/plugin/messages-transform.ts @@ -1,20 +1,9 @@ import type { Message, Part } from "@opencode-ai/sdk" import { log } from "../shared/logger" -import { normalizeModelID } from "../shared/model-normalization" import type { CreatedHooks } from "../create-hooks" 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 = { info: Message @@ -43,17 +32,6 @@ function findLastUserMessage(messages: MessageWithParts[]): UserMessageInfo | un 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 { return typeof value === "object" && value !== null } @@ -80,44 +58,6 @@ function readModelIdentifier(info: unknown): ModelIdentifier | 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( lastAssistantMessage: MessageWithParts, messages: MessageWithParts[], @@ -160,13 +100,6 @@ function ensureUserTurnAfterAssistantTail(output: MessagesTransformOutput): void return } - const shouldRepairAssistantTail = hasInternalContinuationTrigger(output.messages) || - shouldRepairAssistantPrefillForModel(findLastUserModel(output.messages)) || - shouldRepairAssistantPrefillForModel(readModelIdentifier(lastMessage.info)) - if (!shouldRepairAssistantTail) { - return - } - output.messages.push(createAssistantPrefillRecoveryMessage(lastMessage, output.messages)) }