Repository navigation
Description
Description
An email/MCP workflow on a locally patched OpenCode 2.0.28 backend failed to send with a local request-lowering error, rather than an HTTP error from the model:
2026-10-13T22:18:58.032Z level=ERROR message="Failed to drain Session"
AI.Error: OpenAI Chat tool messages only support tool-result content for now
OpenAIChat.fromRequest
LLM.compile
SessionStep.attempt
SessionRunner.runStep
SessionRunner.drain
cause: AI.Error.InvalidRequest: OpenAI Chat tool messages only support tool-result content for now
The guard also exists in the unmodified v2.0.28 source: packages/ai/src/protocols/openai-chat.ts, lowerToolMessages, rejects each top-level tool-message content part that fails ProviderShared.supportsContent(part, ["tool-result"]). This is a message-envelope content-type rejection, not rejection of a media MIME type nested inside a valid tool result. The request never reaches the provider.
I am not proposing that the guard be removed. The unresolved question is how the core/plugin pipeline produced a tool-role message containing a different top-level content part. At minimum, please include the offending message/part index and content type in the error, and surface a recovery path rather than only a failed-drain exception. If this input should be impossible, a regression at the core-to-AI request boundary would help identify the producer. Any fallback must retain tool-call/result pairing; silently discarding arbitrary tool history would be unsafe.
Related but different: #50775/#50776 concern a non-array tool-result.result.value during core preparation; #52448/#54798 concern unsupported MIME types inside tool results. Neither addresses this top-level role/content guard.
Plugins
OpenChamber integration, OMO, context-mode, Meridian, EnvSitter guard, OpenCode Claude provider, and Superpowers. MCP tools were available. No specific plugin or MCP server has been isolated as the producer.
OpenCode version
2.0.28, Windows x64, local r1 build carrying six runtime/location/plugin fixes. The OpenAI Chat lowering guard is unchanged from the release tag. This is not yet a clean-stock end-to-end reproduction.
Steps to reproduce
- Observed: use an existing tool-using email/MCP session through the OpenAI Chat route, then send another prompt. Session drain failed with the stack above. Exact model and offending history part were not captured in the redacted log evidence.
- Suggested isolating test: supply a tool-role message with a non-
tool-resulttop-level content part toOpenAIChat.fromRequest; confirm this preciseInvalidRequestguard, distinct from media lowering errors. - To isolate the real producer, capture only role, message/part index, and content-type discriminants immediately before
LLM.compile, then replay a redacted minimal request. Do not log email contents or credentials. I have not yet performed that capture or established that every later prompt fails.
Expected: actionable validation at the producer boundary and an identifiable recovery path. Actual: sends in the affected workflow failed with the generic failed-drain exception.
Screenshot and/or share link
Redacted stack above; private email history is not shared.
Operating System
Windows 11, win32 x64.
Terminal
OpenChamber desktop client.
Activity
opencode-agent commentedon Oct 14, 2026
Thanks for the detailed report and for pointing to the exact guard. You're right that the check in
lowerToolMessages(packages/ai/src/protocols/openai-chat.ts) is still in thev2.0.28tag, and its error doesn't say which message or part failed.We can't reproduce this yet, because nothing in the report shows what put a non-
tool-resultpart into the tool message. That could be core or one of the plugins. Could you add:typeof each content part (no text or email content), especially the content type of the part in the tool message that fails. Even one failing message/part pair would help.You can reply here with the details, or edit the issue. If you edit it, please also leave a comment saying you've updated it so we know you're done.
Related issues I found, ordered by how likely they are to cover your problem (none matches this exact error):
tool-resultvalue)mradwankhalil commentedon Oct 14, 2026
Follow-up to the four diagnostic questions:
OpenAIChat.fromRequeststack alone. That stack identifies the lowering route, not a provider ID.v2.0.28, but that does not establish that stock core produced the offending message. The observed backend had local runtime patches and the listed plugins. No plugin is implicated by the current evidence.tool-result. It does not tell us whether the offending part was text, media, or another variant. I will not infer a MIME error or publish email history. The useful next diagnostic is role/index/type-only instrumentation before compilation, followed by a redacted boundary replay and plugin-disabled isolation.2026-10-13T22:18:58.032Z. The workflow's sends failed, but I have not established that every later prompt in the same session repeats this exact error. The issue deliberately does not claim a permanent wedge.For now this is blocked on capture/isolation, not a ready-to-merge fix. The independently supported improvement is diagnostic context for
unsupportedContentplus a safe recovery path that preserves call/result pairing. Please do not treat the source-level negative-test recipe as an end-to-end reproduction of the real producer.opencode-agent commentedon Oct 14, 2026
Thanks for the careful follow-up. Agreed: the guard being in stock
v2.0.28doesn't show where the tool message came from, and callingOpenAIChat.fromRequestdirectly isn't an end-to-end reproduction. Without the source, or at least the part type and a stock/plugins-off run, we can't reproduce this. The issue stays open for that, and your suggestion to add the message/part index and content type to theunsupportedContenterror is noted for maintainers.If it helps with the capture, the V2
contextsession hook gives a plugin the exactmessagesarray that gets lowered for the model call, so you can log only roles, indexes and parttypes:Caveats: I couldn't type-check or run this here, so treat it as a starting point. It also only sees the messages as they are when the hook runs. If nothing is logged but the drain still fails, the bad part is being added after this hook, which narrows down where it comes from too.
Once you have one of these, please post it here:
offendingentry (message index, part index, part type), orThis issue will be closed automatically on 2026-10-17 if nothing new comes in. If that happens before you've captured it, feel free to open a new issue with the captured details and link this one.
mradwankhalil commentedon Oct 14, 2026
Additional capture from the reporter's machine (read from the local session store; no message contents included).
opencode-go/deepseek-v4.1-flash(OpenAI-chat-compatible routing). The failed drain (2026-10-13T22:18:58.032Z) followed an ordinary, completedshelltool result; the assistant message for that drain persisted empty, then the session went idle.type: "tool".statekey shapes:{content,input,metadata,status}×2606;{error,input,status}×49;{error,input,metadata,status}×11;{content,error,input,metadata,status}×9;{input,metadata,status}×1 — the last is an in-flight running call (transient by design). Errored parts (state.error) exist across the session's lifetime, but the nearest one to the failure predates it by ~24 h, so the timeline does not point at an errored part.contextsession hook you suggested.opencode-agent commentedon Oct 14, 2026
Thanks for the capture, it's very useful. Since the next prompt 96 seconds later and every prompt after it worked, this was a one-off failure, not a stuck session, so there's nothing we can reproduce right now.
One thing it does narrow down: in stock code, core builds tool-role messages only from tool results (
Message.toolinpackages/core/src/session/runner/to-llm-message.ts, and the missing-result filler inpackages/ai/src/tool-history.ts). A non-tool-resultpart in a tool message most likely came from something that edits the messages after core builds them: a plugin using the sessioncontexthook, or one of the local patches. I haven't confirmed which, but the logging hook from my previous comment should catch it if it happens again.So I'm treating what's left as an improvement request: when the OpenAI Chat lowering rejects a tool-message part, the error should give the message index, part index and part type, ideally at a point that shows which step or plugin added it, instead of only a generic failed drain. Maintainers will consider it; no promises on whether or when. A plugin can detect and log bad parts, but it can't change that error message, so this needs a change in core.
If it happens again and you capture the offending indexes/types, or find which plugin adds the part, please post them here or in a new issue.
Related (different causes):
mradwankhalil commentedon Oct 15, 2026
Found the producer — a plugin, not core. Three more occurrences on 2026-10-14 (2.0.28, OpenAI Responses twice, then Anthropic Messages after switching the session's model), all reproduced from the stored history.
Mechanism. The OMO OpenCode V2 adapter (code-yeongyu/oh-my-openagent#9016, which we run) registers a
contexthook that convertsevent.messagesto V1{ info, parts }views, runs OMO's V1experimental.chat.messages.transformchain, and writes the views back by array index. One of those transforms (team-mode status injector) splices a message in before the latest user-role message, so every later message receives its neighbour's content. In an agent loop the latest user-role message is often asyntheticinstruction message (an AGENTS.md pulled in by aread), between two tool steps:The stored history is valid throughout (core's
toLLMMessagesemits onetool-resultper tool message); the bad tool message exists only inside the request. That matches the earlier report: transient, and the next real user prompt clears it because the transform's target changes.The suggested logging hook did not catch it because hooks run in registration order and the directory plugin was set up before the configured OMO plugin, so it saw the messages before OMO edited them.
Fix: SwarmStorm/oh-my-openagent#1 (write back by identity). On our build we also carry a small core guard in
SessionModelRequest.prepare: snapshot the messages before the session hooks; if the hooked list has a tool message with a non-tool-resultpart and the snapshot has none, log the offendingindex:typepairs and send the snapshot. Tool-call/result pairing is preserved, valid hook edits are kept, and pre-existing invalid history is untouched. Happy to open it as a PR here if that direction is wanted; it would also cover the "which plugin added it" part of this issue.akushonkamen commentedon Oct 15, 2026
Confirmed the root cause from source:
lowerToolMessagesinpackages/ai/src/protocols/openai-chat.ts(line ~501 on dev) checks each tool-message part withProviderShared.supportsContent(part, ["tool-result"])and on failure callsProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"]), whose signature inpackages/ai/src/protocols/shared.tsonly takes route/role/types — so the error string structurally cannot include the message index, part index/type, or tool_call_id. As noted above in this thread, the error should identify the offending message and part.I'm preparing a small fix: add an optional
detailparameter tounsupportedContent(backward-compatible, other protocols unaffected) and pass message/part context fromlowerToolMessages. Behavior unchanged — the drain still aborts; only the diagnostics improve.Disclosure: I'm an AI-assisted contributor working on this fix; a human reviews and submits it.
akushonkamen commentedon Oct 15, 2026
Update: this is now a PR — #55620 (branch
akushonkamen:fix/55271-tool-content-error-detail), implementing exactly the change described above: optionaldetailparameter onunsupportedContent(20+ existing call sites untouched) and amessage #N part #M type=<t>(+tool_call_idwhen present) locator fromlowerToolMessages. Behavior is unchanged — the drain still aborts before the request reaches the provider.Verification: new regression test fails on
devwithExpected to contain: "message #2"and passes with the fix;cd packages/ai && bun test test/→ 1811 pass / 28 skip / 0 fail across 135 files;bun run typecheckexit 0;oxlinton the three changed files → 0 warnings / 0 errors.mradwankhalil commentedon Oct 15, 2026
Thanks for taking on the diagnostics improvement and sharing the failing-first test. The reported scope of #55620 matches the diagnostic gap here: identify the offending message/part without changing the drain's rejection behavior.
That complements the plugin-side producer fix in SwarmStorm/oh-my-openagent#1 (identity-based hook write-back). They address separate layers; the adapter fix does not remove the value of locating malformed parts when another producer introduces them. I have not independently rerun #55620's tests.