You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[FEATURE]: Plugin API: change or remove a stored message or part #57213
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.
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.
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:
Stale notice: a replayable retract/correct event that updates the stored view and future context while preserving stable IDs and references.
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.
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.
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.
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.
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.
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.
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.
Activity
opencode-agent commentedon Oct 19, 2026
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
messageoperations at all, and the host API only hasmessage.get. So this can't be done as a plugin today, and maintainers will consider adding something likesession.message.update/session.message.removeto the plugin API.A partial workaround for the credential case: the plugin
contexthook (andgenerate) receives a mutablemessagesarray 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.Dante-dan commentedon Oct 20, 2026
One existing event needs care before adding this API:
MessageContentUpdatedis 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:
Busretains the original encoded event, so appending a replacement or deleting the projected message does not erase the earlier payload (the distinction in [BUG/SECURITY]: redacted secrets persist in cleartext in the opencode.db event journal #52586).Would the first API be scoped to replayable transcript corrections/retractions, with historical-payload redaction designed separately? That would make it clear whether
removemeans “no longer visible/used” or actual secret erasure before plugins rely on it.iceteaSA commentedon Oct 20, 2026
Yes, that split fits both of our use cases.
Stale notice. Our plugin admits notices with
ctx.session.syntheticandctx.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: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
removeshould not imply it. One data point for that design: the local service does not write the event journal.events.persistis set only inpackages/server/src/workerd.ts, andBus.configuredinserts rows only whenpersistis 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/removeas "no longer visible or used", with no erasure claim, that works for us. Today we cover the model side with thecontexthook. The stored transcript and UI are the remaining gap.Dante-dan commentedon Oct 20, 2026
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 pluginSessionDomaindoes 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:
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
persistbeing 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.iceteaSA commentedon Oct 20, 2026
That contract fits our use. Two notes from the plugin side:
When a branch exists, we can run it against a plugin test suite that admits notices through
synthetic/promptin an isolated service, and report the results for the acceptance cases you listed.Dante-dan commentedon Oct 21, 2026
For the busy/unknown-ID distinction, there is already a public error contract to follow:
SessionBusyErroris tagged and maps to HTTP 409, whileMessageNotFoundErrormaps to 404.session.revert.stagealready 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/promptsuite would be useful for checking that boundary when a branch is available. These are proposed semantics and checks, not implementation test results.iceteaSA commentedon Oct 21, 2026
SessionBusyError(409) andMessageNotFoundError(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_tagand nocode. For example,ctx.session.geton 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.