Skip to content

[FEATURE]: Plugins have no way to transform assistant text for display (or on completion) on v2 #53019

Open

Description

@ualtinok

Feature hasn't been suggested before.

  • I have verified this feature I'm about to request hasn't been suggested before.

Describe the enhancement you want to request

Use case

Some plugins have the model write small markers into its own replies. For example, a context-management plugin asks the model to tag messages with §N§ so it can refer back to them. These markers are meant for the plugin, not the user. On v1, experimental.text.complete lets a plugin rewrite an assistant text part when it completes, before it is saved, so the markers never reach the stored transcript or the UI.

On v2 there is no equivalent, so the markers are saved in session_message and shown in the timeline.

What v2 offers today (checked at upstream/v2 d1f8f5b, same as v2.0.22)

  • SessionHooks (packages/plugin/src/promise/session.ts) is prompt, context, compaction, generate, title, model.request, http.request, http.response, experimental.ws.handshake, experimental.ws.send, experimental.ws.receive and retry. Nothing runs between the parsed model reply and persistence, or before rendering.
  • The TUI plugin API's only markdown seam is markdown.registerCodeBlockRenderer(language, render) (packages/plugin/src/tui/context.ts), which handles fenced code blocks by language, not prose. The slot map has no per-message or per-part slot.
  • The closest workaround is to replace the provider's raw response in http.response (or edit frames in experimental.ws.receive) and strip the markers from the wire stream before OpenCode parses it. That works, but it is provider-specific: a marker can be split across SSE events or JSON-escaped deltas, and every protocol (Anthropic, OpenAI Responses, Gemini, ...) needs its own parser.

Proposal (either would cover it; the first is preferred)

  1. A display hook that runs before assistant text is rendered, in the TUI and the app, letting a plugin hide its own markers from what the user sees. The stored text and what is sent back to the model stay exactly as the provider returned them. This is the better fit because it doesn't change history: prompt-cache prefixes stay byte-stable, and the WebSocket continuation path, which compares stored output byte for byte, is unaffected.
  2. A text.complete equivalent: a session hook that runs when an assistant text part completes, with the part text mutable, before it is persisted. This is what v1 does today.

Related issues

These cover nearby ground on v1 and are closed, so none of them tracks v2:

Activity

  1. changed the title [-]Plugins have no way to transform assistant text for display (or on completion) on v2[/-] [+][FEATURE]: Plugins have no way to transform assistant text for display (or on completion) on v2[/+] on Oct 4, 2026
  2. github-actions commented on Oct 4, 2026

    @github-actions
    Contributor

    Thanks for updating the issue!

  3. tobwen commented on Oct 11, 2026

    @tobwen
    Contributor

    This is really important, as the markers destroy OpenCode's ability to recognize Markdown correctly.

  4. circularnatural commented on Oct 14, 2026

    @circularnatural

    Another use case that breaks on v2: output validation and repair, not just marker stripping.

    I run a small plugin that audits every completed assistant reply against a fixed closing format
    (required section headers, their order, and evidence lines when the reply claims something was
    tested) and repairs it in place before it is persisted. On v1 this is a few lines on
    experimental.text.complete: read output.text, rewrite it, log what was fixed.

    On v2.0.28 the plugin loads (dual setup/server entrypoint), but the V2 path has to stay a no-op:
    none of the SessionHooks run between the parsed reply and persistence, and rewriting the wire
    stream in http.response would mean a per-provider SSE parser for a text-level check.

    Either option from the proposal would cover it. A completion-time hook would be enough for us,
    since the repair has to reach the stored transcript, not just the rendered view, e.g.:

    ctx.session.hook("text.complete", (event) => { event.text = repair(event.text) })
    

    with sessionID, messageID and partID on the event, like v1.

  5. added a commit that references this issue on Oct 24, 2026
  6. Trollgeir commented on Oct 25, 2026

    @Trollgeir

    Another related use case: display-only transcript projection for context-management plugins.

    Lacuna replaces archived conversation content with summaries in outgoing model requests, while keeping stored session history unchanged. The UI still shows every original turn, so the visible conversation no longer reflects the model’s active context.

    Could the display-only proposal extend beyond transforming individual text parts to changing how transcript content is presented? For Lacuna, that would mean grouping archived messages and tool results into expandable summary blocks.

    Importantly, expanding a block would reveal the originals to the user only, without restoring them into model context. Stored history would remain untouched, and disabling the plugin would return the normal transcript.

    A text-only hook wouldn’t cover this, but a more general presentation hook could support grouping, annotations, and other plugin use cases too.

  7. opencodejr commented on Oct 27, 2026

    @opencodejr

    Thanks for the detailed write-up, @ualtinok!

    To restate the request: in v2, plugins have no way to change assistant text before it is shown or saved. That means plugin-internal markers such as §N§ end up in the stored transcript and the timeline. You'd like either a display-only transform hook (preferred, since it leaves the stored text and the model context unchanged) or a session hook like v1's experimental.text.complete.

    I checked the current v2 plugin API and agree there's a gap. SessionHooks has nothing that runs between the parsed model reply and saving it. On the TUI side, markdown.registerCodeBlockRenderer only handles fenced code blocks. Rewriting the stream in http.response / experimental.ws.receive is the only workaround today, and it needs a separate parser for each provider. So this can't be built cleanly as a plugin right now, and I've passed the two options on to the maintainers to consider.

    Related issues (all v1 and closed, none of them tracks v2):

  8. luanweslley77 commented on Nov 3, 2026

    @luanweslley77
    Contributor

    Another data point from a v1 plugin author, because the plugin name here is misleading: I maintain a formatting plugin whose scope is not just Markdown tables. It normalizes text artifacts across three different surfaces, and all three lose their seam on v2.

    What the plugin actually does

    • Pipe tables: cell alignment by rendered width, separator alignment (:---:, ---:), minimum width, and for .md files a column-shrink + line-wrap pass so a wide table fits the editor window (231-col default; idempotent).
    • Box-drawing diagrams: it repairs boxes, ports (┬/┴/┼), multi-cell spans, and routes arrows (▼/▲) with 90° elbows to the target box center, fills stems, aligns horizontal arrows/ports, and fixes fishbone/timeline axes and git-graph pipes.
    • Display-width models: two renderers — terminal (CJK=2, emoji=2, box-drawing=1) and editor/.md (CJK=5/3, ▶=2 with Noto Sans Mono, measured from system fonts via fc-match + SFNT parsing).
    • Normalization: tabs in cells, CRLF/LF preservation, no-op when nothing changes.

    How it hooks v1 today

    1. experimental.text.complete — rewrites the finished assistant text before it is stored (visual model).
    2. event: file.edited — re-reads the .md file and writes the raw-formatted result back to disk.
    3. event: message.part.updated (reasoning parts) — text.complete does not fire for thinking, so the plugin formats the stored reasoning part and patches it through the internal HTTP client (client._client.patch(.../part/...)).

    What v2 lacks for this plugin

    • No completion/display seam for assistant text (as the issue says). A display-only transform would cover surface 1 and arguably 2, but not the stored text if persistence also matters to other plugins.
    • No public API to update a message part. Reasoning formatting currently depends on an internal client; on v2 there is no equivalent surface at all, so even a context/display transform would leave reasoning unhandled.
    • No file.edited event (the v2 event list has only session.*), so the on-disk .md formatting has no trigger.
    • markdown.registerCodeBlockRenderer only covers fenced code, not tables or box-drawing prose.

    The only v2 workaround mentioned in the issue (rewriting http.response or experimental.ws.receive) would require a separate stream parser per provider (Anthropic/OpenAI/Gemini/...), which is exactly the kind of lifecycle assumption a plugin should not have to preserve.

    Question for maintainers: is the proposed display-only transform scoped to assistant text only, or could it cover (a) reasoning parts before persistence and (b) edited .md files? And is a public "update stored part" API planned as part of that, so plugins don't need internal HTTP patches?

  9. tobwen commented on Nov 4, 2026

    @tobwen
    Contributor
    Image

    In my own fork, I've kept the markers, since it's a reference when looking up things in exported markdown files or inside the opencode-db. I've just thrown them on a seperate line and muted the color. So it doesn't interfere when rendering markdown.

    Maybe I'll add a way to collapse them...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions