diff --git a/src/plugin/messages-transform.test.ts b/src/plugin/messages-transform.test.ts index dca36f03c..acd85abd5 100644 --- a/src/plugin/messages-transform.test.ts +++ b/src/plugin/messages-transform.test.ts @@ -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 + 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 { diff --git a/src/plugin/messages-transform.ts b/src/plugin/messages-transform.ts index e301c4efc..083944679 100644 --- a/src/plugin/messages-transform.ts +++ b/src/plugin/messages-transform.ts @@ -1,9 +1,21 @@ 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", + "opencode", +]) +const ASSISTANT_PREFILL_UNSUPPORTED_MODEL_PREFIXES = [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-mythos", +] type MessageWithParts = { info: Message @@ -12,6 +24,10 @@ type MessageWithParts = { type MessagesTransformOutput = { messages: MessageWithParts[] } type UserMessageInfo = Extract +type ModelIdentifier = { + providerID: string + modelID: string +} function getSessionID(message: MessageWithParts): string | undefined { return message.info.sessionID @@ -43,6 +59,53 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } +function readStringField(record: Record, 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 { if (!isRecord(part)) { return false @@ -63,7 +126,7 @@ function createAssistantPrefillRecoveryMessage( const lastUserMessage = findLastUserMessage(messages) const sessionID = getSessionID(lastAssistantMessage) ?? lastUserMessage?.sessionID ?? "" const messageID = `${lastAssistantMessage.info.id}_prefill_recovery` - const model = lastUserMessage?.model ?? { + const model = readModelIdentifier(lastUserMessage) ?? { providerID: "internal", modelID: "assistant-prefill-guard", } @@ -98,7 +161,9 @@ function ensureUserTurnAfterAssistantTail(output: MessagesTransformOutput): void return } - if (!hasInternalContinuationTrigger(output.messages)) { + const shouldRepairAssistantTail = hasInternalContinuationTrigger(output.messages) || + shouldRepairAssistantPrefillForModel(findLastUserModel(output.messages) ?? readModelIdentifier(lastMessage.info)) + if (!shouldRepairAssistantTail) { return }