Problem
opencode webui (especially on mobile) intermittently returns:
This model does not support assistant message prefill. The conversation must end with a user message.
This rejects requests on Claude Opus/Sonnet 4.x (Anthropic native, GitHub Copilot, OpenRouter, Bedrock, Vertex — all providers that proxy these models).
Root cause
Anthropic rejects conversations that end with an assistant message for Claude 4.x. This state occurs in opencode in three documented ways:
- Aborted/interrupted previous turn (sst/opencode#13768, PR #14772) — the aborted assistant has
finish undefined, so the continuation gate at session/prompt.ts (lastAssistant2?.finish && !["tool-calls"].includes(...)) fails open and the next iteration calls the LLM with a trailing assistant still present. Common on mobile where flaky connections cause aborts.
- Continuation loop bug (sst/opencode#17982, PR #16921) — opencode 1.3.17 has a partial gate but it requires
finish to be set.
- Max-steps hint (PR #14772) — when max steps is reached, opencode appends
{role: "assistant", content: max_steps_default} after the plugin hook runs.
Three upstream PRs (#14772, #16921, #18091) all add stripTrailingAssistant() in provider/transform.ts. None are merged as of opencode v1.3.17 / v1.4.0.
Fix
Apply the same defensive strip at the plugin layer via experimental.chat.messages.transform, which runs immediately before MessageV2.toModelMessages() in session/prompt.ts:837128.
Files
NEW: .agents/plugins/opencode-aidevops/prefill-guard.mjs — pure module exporting createPrefillGuardHook(deps). Strips trailing assistant messages from output.messages only when safe (preserves messages with finish === "tool-calls" or active pending/running/completed tool parts so legitimate tool-call flows are untouched). Wrapped in try/catch so a bug here can never break the hook chain.
EDIT: .agents/plugins/opencode-aidevops/index.mjs — imports createPrefillGuardHook, composes a hook that runs messagesTransformHook first (so TTSR sees all assistants for rule scanning) then prefillGuardHook (so anything TTSR didn't fix gets stripped).
Reference pattern
Composition mirrors the existing messagesTransformHook wiring at index.mjs:204. The guard module follows the same shape as ttsr.mjs (factory + closure pattern with qualityLog injection).
Safety
- Session DB is never modified — stripped messages still display in transcript history.
- Tool-call flows are preserved (only assistants with no live tool calls are stripped).
- Never throws — try/catch with diagnostic logging.
- Composition order: TTSR runs first (it scans the last 3 assistants for rule violations and may append a synthetic user correction); guard runs second so if TTSR didn't append a correction AND the conversation still ends with an assistant, we strip it.
Verification
node --check .agents/plugins/opencode-aidevops/prefill-guard.mjs (syntax)
node --check .agents/plugins/opencode-aidevops/index.mjs (syntax)
- Restart
opencode-web.service and confirm clean plugin load in journalctl --user -u opencode-web.service
- Send a message via mobile webui that previously triggered the prefill error — should now succeed. Stripped messages produce a journal line:
[aidevops prefill-guard] stripped N trailing assistant message(s) ...
Acceptance criteria
Related upstream
aidevops.sh v3.6.162 plugin for OpenCode v1.3.17 with claude-opus-4-6 spent 1h 12m and 52,593 tokens on this with the user in an interactive session.
Problem
opencode webui (especially on mobile) intermittently returns:
This rejects requests on Claude Opus/Sonnet 4.x (Anthropic native, GitHub Copilot, OpenRouter, Bedrock, Vertex — all providers that proxy these models).
Root cause
Anthropic rejects conversations that end with an assistant message for Claude 4.x. This state occurs in opencode in three documented ways:
finishundefined, so the continuation gate atsession/prompt.ts(lastAssistant2?.finish && !["tool-calls"].includes(...)) fails open and the next iteration calls the LLM with a trailing assistant still present. Common on mobile where flaky connections cause aborts.finishto be set.{role: "assistant", content: max_steps_default}after the plugin hook runs.Three upstream PRs (#14772, #16921, #18091) all add
stripTrailingAssistant()inprovider/transform.ts. None are merged as of opencode v1.3.17 / v1.4.0.Fix
Apply the same defensive strip at the plugin layer via
experimental.chat.messages.transform, which runs immediately beforeMessageV2.toModelMessages()insession/prompt.ts:837128.Files
NEW: .agents/plugins/opencode-aidevops/prefill-guard.mjs— pure module exportingcreatePrefillGuardHook(deps). Strips trailing assistant messages fromoutput.messagesonly when safe (preserves messages withfinish === "tool-calls"or activepending/running/completedtool parts so legitimate tool-call flows are untouched). Wrapped in try/catch so a bug here can never break the hook chain.EDIT: .agents/plugins/opencode-aidevops/index.mjs— importscreatePrefillGuardHook, composes a hook that runsmessagesTransformHookfirst (so TTSR sees all assistants for rule scanning) thenprefillGuardHook(so anything TTSR didn't fix gets stripped).Reference pattern
Composition mirrors the existing
messagesTransformHookwiring atindex.mjs:204. The guard module follows the same shape asttsr.mjs(factory + closure pattern withqualityLoginjection).Safety
Verification
node --check .agents/plugins/opencode-aidevops/prefill-guard.mjs(syntax)node --check .agents/plugins/opencode-aidevops/index.mjs(syntax)opencode-web.serviceand confirm clean plugin load injournalctl --user -u opencode-web.service[aidevops prefill-guard] stripped N trailing assistant message(s) ...Acceptance criteria
Related upstream
finish=stop, triggering prefill error on claude-opus-4-6 anomalyco/opencode#17982 — continuation loop analysisaidevops.sh v3.6.162 plugin for OpenCode v1.3.17 with claude-opus-4-6 spent 1h 12m and 52,593 tokens on this with the user in an interactive session.