Skip to content

[FEATURE]: Plugin API: change or remove a stored message or part #57213

Open

Description

@iceteaSA

Problem

A plugin cannot change a stored message. A plugin cannot remove a stored message or a message part.

Source (v2.0.31)

  • packages/client/src/effect/api/api.ts:1344 shows the only message operation on the host session API:

    readonly message: { readonly get: SessionMessageGetOperation<E> }

    There is no update and no remove.

  • packages/plugin/src/promise/session.ts:153 selects the host operations for plugins. The selection has no message entry at all.

Purpose

A model writes a credential into its own reply. A plugin must remove the credential from the stored reply. A plugin stores a notice, then the notice becomes stale. The plugin must retract the stored notice. The pre-delivery inbox cancel does not help after the host stores the message.

Proposed minimal API

Add session.message.update and session.message.remove to the plugin session domain. An update gets a message id and a replacement body or part list. A remove gets a message id.

What the plugin does today without it

The plugin cannot correct the stored text. The credential stays in the transcript and in the next model request.

Activity

  1. opencode-agent commented on Oct 19, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed request, @iceteaSA! You're asking for plugins to be able to change or remove a stored session message or part, for example to strip a credential a model wrote into its own reply, or to retract a notice that has gone stale. You've read the code right: in v2 the plugin session domain has no message operations at all, and the host API only has message.get. So this can't be done as a plugin today, and maintainers will consider adding something like session.message.update / session.message.remove to the plugin API.

    A partial workaround for the credential case: the plugin context hook (and generate) receives a mutable messages array before each model request. A plugin can redact the secret there, so it isn't sent to the model again. It will still be in the stored transcript and UI, though, and this doesn't help with taking back a stale notice.

  2. Dante-dan commented on Oct 20, 2026

    @Dante-dan
    Contributor

    One existing event needs care before adding this API: MessageContentUpdated is explicitly marked “Replay-only”; its projector supports older releases replacing completed assistant content. Reusing that projector alone would not define safe update/delete behavior for every message type.

    I'd separate the two use cases in the contract:

    Would the first API be scoped to replayable transcript corrections/retractions, with historical-payload redaction designed separately? That would make it clear whether remove means “no longer visible/used” or actual secret erasure before plugins rely on it.

  3. iceteaSA commented on Oct 20, 2026

    @iceteaSA
    Author

    Yes, that split fits both of our use cases.

    Stale notice. Our plugin admits notices with ctx.session.synthetic and ctx.session.prompt, for example a scheduled reminder or a child-completion notice. When a later event makes a notice obsolete, we need a replayable correction that:

    • keeps the message ID, so references and dedup keys stay valid;
    • changes both the stored view and future context.

    A text replacement of a text part, or a whole-message retraction with a short host-rendered placeholder, covers this. We do not need to delete messages for it.

    Credential removal. Agreed: this is a separate privacy guarantee, and remove should not imply it. One data point for that design: the local service does not write the event journal. events.persist is set only in packages/server/src/workerd.ts, and Bus.configured inserts rows only when persist is true (bus.ts#L649 at v2.0.32). So on that deployment the journal is not a second copy, and a correction of the projected rows covers the transcript store. Deployments that persist events still need the separate design from #52586.

    If the first API documents update/remove as "no longer visible or used", with no erasure claim, that works for us. Today we cover the model side with the context hook. The stored transcript and UI are the remaining gap.

  4. Dante-dan commented on Oct 20, 2026

    @Dante-dan
    Contributor

    That gives the first version a concrete scope: text correction and whole-message retraction for notices admitted through session.synthetic / session.prompt, without a hard-delete operation.

    There are two API layers to wire up: the client API currently lists only message.get, and the plugin SessionDomain does not include that message domain. This needs an explicitly exposed plugin operation as well as a host-side mutation contract.

    I'd define that contract as follows:

    • A correction replaces an existing text part without changing the message ID or part ID. It does not change the message role or rewrite tool-call/result relationships.
    • A retraction preserves the message ID and a host-rendered placeholder for references and deduplication, while excluding the retracted notice's original content from subsequent model context. The placeholder should be host-owned rather than additional model-facing plugin text.
    • Both operations go through a new replayable event and the normal projector/client update path, so replay, reopening the session, the live UI and future context agree. They should apply between turns; an active turn must reject the mutation or serialize it at a documented boundary, rather than changing content underneath an already-built model request.

    The acceptance cases would be: correcting and retracting a notice, preserving IDs/references, reopening/replaying the session, checking the subscribed UI and the next model request, and handling a concurrent active turn. Those are proposed checks, not results from an implementation.

    You're right about persistence: my journal warning is conditional on persist being enabled. For the local service described here, there is no journal copy to redact. The API should still promise transcript correction/retraction rather than credential erasure; deployments that persist events need the separate historical-payload design in #52586.

  5. iceteaSA commented on Oct 20, 2026

    @iceteaSA
    Author

    That contract fits our use. Two notes from the plugin side:

    • Active turns: rejecting a mutation during an active turn works for us. Our notices are corrected or retracted when a later event supersedes them, and we can retry at the next idle boundary. Please make the rejection a typed error, so a plugin can tell "busy, retry later" apart from "unknown ID".
    • Placeholder: a host-owned placeholder is better for us than plugin text, because it cannot carry new model-facing content.

    When a branch exists, we can run it against a plugin test suite that admits notices through synthetic/prompt in an isolated service, and report the results for the acceptance cases you listed.

  6. Dante-dan commented on Oct 21, 2026

    @Dante-dan
    Contributor

    For the busy/unknown-ID distinction, there is already a public error contract to follow: SessionBusyError is tagged and maps to HTTP 409, while MessageNotFoundError maps to 404. session.revert.stage already declares both errors. I'd use the same distinction for correction/retraction and preserve it through the plugin-facing API, rather than require matching an error-message string.

    The proposed concurrency contract should also say that a busy rejection commits no correction/retraction event and changes no projected content. Admission of the mutation must be coordinated with turn startup: an idle check alone would leave a race if a turn starts before the correction commits. Retrying at an idle boundary is allowed, but is not a reservation; if another turn wins admission, the plugin gets the typed busy error again.

    That adds three concrete cases to the proposed acceptance list: a valid notice ID during an active turn returns the typed busy error with unchanged transcript/event state; an unknown message ID while idle returns the distinct not-found error; and a retry after idle either succeeds with the existing stable IDs or returns busy again if a new turn wins the race. The same cases should exercise the plugin-facing error shape, as well as the host API. Your isolated synthetic/prompt suite would be useful for checking that boundary when a branch is available. These are proposed semantics and checks, not implementation test results.

  7. iceteaSA commented on Oct 21, 2026

    @iceteaSA
    Author

    SessionBusyError (409) and MessageNotFoundError (404) work for us, and the three added cases include the retry path that is necessary for us. Our plugin retries with backoff, and a busy error that occurs again is a usual result for it.

    One note on the plugin-facing shape: in 2.1.1, a host error gets to a plugin through ctx.session.* as an object with _tag and no code. For example, ctx.session.get on a deleted session rejects with { _tag: "Session.NotFoundError", sessionID }, and our plugin compares _tag. If the mutation operations reject with the same shape for the busy and not-found errors, plugins can use the same comparison. Please include that shape in the plugin-facing acceptance case.

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

Metadata

Metadata

Assignees

No one assigned

    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