Repository navigation
fix(core): pass stored message metadata to session request hooks - #63590
Conversation
There was a problem hiding this comment.
This works: context hook messages carry the stored metadata again, and it doesn't reach the provider. I ran the canary plugin from #63252 against the simulated model. On dev the token never reaches the request. With this change the token is in the request and the request has no metadata key. The new test fails on dev and passes here. The touched session test files pass (319 tests) and core and plugin type-check clean with tsc.
Public API: @opencode/plugin (Effect and Promise) gains SessionRequestMessage = Message & { readonly metadata?: Record<string, unknown> }. SessionRequest.messages (the context, compaction and generate hooks) changes from Array<Message> to Array<SessionRequestMessage>. This is backwards compatible: plugins that read or replace messages still type-check and behave the same. The @opencode/ai Message type and the provider request are unchanged.
One thing worth fixing: the metadata is only stripped in SessionModelRequest, so native trigger compaction saves it into the stored replay window (inline comment).
Related: #60008
| * Translate projected Session history into canonical @opencode/ai context. Messages built from a stored message carry | ||
| * its metadata for plugin hooks; SessionModelRequest strips it before the model request. | ||
| */ | ||
| export const toLLMMessages = ( |
There was a problem hiding this comment.
toLLMMessages has callers outside SessionModelRequest. In native trigger compaction, recentUserMessages (compaction.ts) builds the kept user messages with this function, and toResult saves them with JSON.parse(JSON.stringify(window)). Each kept user message's metadata (for example a plugin's injected recall text, or anything else in stored message metadata) is now copied into the saved native.messages window. It is dropped again when the window is read back, so the provider never gets it, but it contradicts the 'stripped before the model request' comment and adds to storage. You could strip it in recentUserMessages too. Another option is to attach metadata only where the hook payload is built, instead of in this shared function.
There was a problem hiding this comment.
Moved the attachment into baseTranscript, where the hook payload is built, and left toLLMMessages unchanged, so native trigger compaction's kept user messages no longer carry metadata.
There was a problem hiding this comment.
Confirmed. At pr/head (b0601c4), to-llm-message.ts is identical to pr/base, so recentUserMessages in compaction and the saved native.messages window no longer pick up metadata. The bot's concern is resolved.
- Where
metadatais added: only inbaseTranscript(model-request.ts~127–135). Theitem.id === message.idguard means it goes only on the message built from the stored message itself, never on the tool-result messages split out of an assistant message. - Where it is removed:
prepare(~339–348) rebuilds any message that hasmetadatawithMessage.make, so it is gone before the provider request. - Doc comment: the comment this thread is on was reverted along with the function, so the stale "stripped before the model request" wording is gone. The new inline comment in
baseTranscriptdescribes the actual behaviour. - Tests: I ran
bun test test/session-runner.test.ts test/session-compaction.test.tsinpackages/coreand all 289 pass. I didn't rerun typecheck orbun run check.
I changed nothing. The bun.lock modification in the checkout isn't mine, and I left it in place.
There was a problem hiding this comment.
The new commit fixes the earlier point: toLLMMessages matches dev again, and stored metadata is added only in baseTranscript, so it no longer gets into native compaction's saved replay window. I re-ran the canary plugin from #63252 with the simulated model: the token reaches the request and no request has a metadata key. The touched session tests pass (319) and core and plugin type-check clean with tsc.
Public API: same as before. This adds SessionRequestMessage to @opencode/plugin (Effect and Promise), and SessionRequest.messages changes from Array<Message> to Array<SessionRequestMessage>. This is backwards compatible.
There was a problem hiding this comment.
The new commit only changes how metadata is removed before the request goes to the model, and it still works. I re-ran the canary plugin from #63252 with the simulated model: the token reaches the request and no request has a metadata key. The touched session tests pass (319) and core type-checks clean with tsc. Nothing new to raise.
Public API: unchanged since the last review. This adds SessionRequestMessage to @opencode/plugin, and SessionRequest.messages changes from Array<Message> to Array<SessionRequestMessage>. This is backwards compatible.
There was a problem hiding this comment.
The new commit still works and I have nothing new to raise. Messages with stored metadata are now copied instead of changed in place. The copies share the original content array, so in-place content edits still apply, and Message.make turns them back into real messages before the request. I re-ran the canary plugin from #63252 with the simulated model: the token reaches the request and no request has a metadata key. The touched session tests pass (320, including the new one) and core type-checks clean with tsc.
Public API: unchanged since the last review. This adds SessionRequestMessage to @opencode/plugin, and SessionRequest.messages changes from Array<Message> to Array<SessionRequestMessage>. This is backwards compatible.
One conflict, in session-compaction.test.ts: dev's anomalyco#63590 added the stored metadata assertions next to this branch's `transient` summary prompt. Both are kept. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_01X7arpqRMkdsRWVTteqLJM9
Since anomalyco#63590, request messages carry the stored Session message's metadata for hooks to read. The goal and Plan reminders replaced the last user message with a new UserMessage, whose constructor drops that field, so a context hook that ran after them no longer saw the prompt's metadata. Spread the message instead. The request is built from it either way: metadata is stripped and the message is made again before it is sent. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_01X7arpqRMkdsRWVTteqLJM9
OpenCode 2.2.x passes hook messages as { id, role, content }. The prompt
metadata Kimaki renders per message (<discord-user>, time since the last
reply) is read from the session history by message ID instead, cached per
session. OpenCode 2.3.0 passes the metadata again (anomalyco/opencode#63590).
Issue for this PR
Fixes #63252
Type of change
What does this PR do?
Plugins can store data on a user message by setting
event.metadatain theprompthook. OpenCode saves it with the message. Some plugins, such as OpenViking, then readmessage.metadatain thecontexthook and add text for that message before each model call.#61018 removed
metadatafrom the@opencode/aiMessage. After that, messages in thecontexthook had onlyid,roleandcontent. These plugins silently stopped doing anything.Hook payload for a user message whose
prompthook setmetadata:{ id: "msg_…", role: "user", content: [{ type: "text", text: "Start" }], + metadata: { canary: { context: ["CANARY-TOKEN"] } } }How it works:
SessionModelRequest.baseTranscriptbuilds the request messages for these hooks from saved session messages. Each message built from a saved message gets that message'smetadata. This covers user, assistant, skill, shell, location-change and checkpoint messages. Tool-result messages split out of an assistant message, system parts and native replay windows get none. The sharedtoLLMMessagestranslation is unchanged, so other users of it, such as the user messages that native compaction keeps in its saved replay window, never carrymetadata.context,compactionandgeneratehooks see these messages. The plugin types (Effect and Promise, kept identical) now declaremessages: Array<SessionRequestMessage>, whereSessionRequestMessage = Message & { readonly metadata?: Record<string, unknown> }. This adds a new public type to the plugin package.SessionModelRequestrebuilds any message that hasmetadatawithMessage.make. The rebuild drops fields outside the message schema, sometadatanever reaches the AI request. Hook edits tocontent, in place or by replacing the message, still apply.The
@opencode/aiMessagetype and the request sent to the provider do not change. The plugin docs (server and Effect) now say these hooks carry storedmetadataread-only and that it is not sent to the model.A side effect: checkpoint messages in these hooks now also show their stored metadata, such as
recent. Like all metadata, it is removed before the request.How did you verify your code works?
packages/core/test/session-runner.test.tsuses the flow from the issue. Aprompthook setsmetadata = { canary: { context: ["CANARY-TOKEN"] } }. Acontexthook findsmessage.metadata.canaryon that user message andunshifts a text part. The test checks that the model request's last message starts withCANARY-TOKEN, that no request message has ametadataproperty, and that the compiled provider body contains the token and nometadatakey. It fails ondevand passes with this change.session-compactionfor the pass-through.bun typecheckinpackages/coreandpackages/plugin. 357 tests across the touched core session files pass.bun run checkpasses (35/35).Visual
Checklist
Added by the opencode bot (AI-generated).
Related: #60008
Public API: adds the SessionRequestMessage plugin type; session request hooks' messages may carry read-only stored metadata (backwards compatible)