Skip to content

fix(core): pass stored message metadata to session request hooks - #63590

Merged
rekram1-node merged 4 commits into
devfrom
context-hook-metadata
Nov 13, 2026
Merged

rekram1-node merged 4 commits into
devfrom
context-hook-metadata

Conversation

@rekram1-node

@rekram1-node rekram1-node commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Issue for this PR

Fixes #63252

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Plugins can store data on a user message by setting event.metadata in the prompt hook. OpenCode saves it with the message. Some plugins, such as OpenViking, then read message.metadata in the context hook and add text for that message before each model call.

#61018 removed metadata from the @opencode/ai Message. After that, messages in the context hook had only id, role and content. These plugins silently stopped doing anything.

Hook payload for a user message whose prompt hook set metadata:

  { id: "msg_…", role: "user", content: [{ type: "text", text: "Start" }],
+   metadata: { canary: { context: ["CANARY-TOKEN"] } } }

How it works:

  • SessionModelRequest.baseTranscript builds the request messages for these hooks from saved session messages. Each message built from a saved message gets that message's metadata. 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 shared toLLMMessages translation is unchanged, so other users of it, such as the user messages that native compaction keeps in its saved replay window, never carry metadata.
  • The context, compaction and generate hooks see these messages. The plugin types (Effect and Promise, kept identical) now declare messages: Array<SessionRequestMessage>, where SessionRequestMessage = Message & { readonly metadata?: Record<string, unknown> }. This adds a new public type to the plugin package.
  • After the hooks run, SessionModelRequest rebuilds any message that has metadata with Message.make. The rebuild drops fields outside the message schema, so metadata never reaches the AI request. Hook edits to content, in place or by replacing the message, still apply.

The @opencode/ai Message type and the request sent to the provider do not change. The plugin docs (server and Effect) now say these hooks carry stored metadata read-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?

  • New test in packages/core/test/session-runner.test.ts uses the flow from the issue. A prompt hook sets metadata = { canary: { context: ["CANARY-TOKEN"] } }. A context hook finds message.metadata.canary on that user message and unshifts a text part. The test checks that the model request's last message starts with CANARY-TOKEN, that no request message has a metadata property, and that the compiled provider body contains the token and no metadata key. It fails on dev and passes with this change.
  • Updated one existing expectation in session-compaction for the pass-through.
  • bun typecheck in packages/core and packages/plugin. 357 tests across the touched core session files pass. bun run check passes (35/35).

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="currentColor">
  <g font-family="monospace" font-size="12"><text x="4" y="24">prompt</text><text x="88" y="24">context</text><text x="186" y="24">model</text></g>
  <path d="M10 44h218" stroke="currentColor" opacity=".3"/>
  <g font-family="monospace" font-size="10"><text x="2" y="64">metadata</text><text x="90" y="64">metadata</text><text x="190" y="64">—</text></g>
  <circle cx="20" cy="44" r="4"><animate attributeName="cx" values="20;110;110;205;20" keyTimes="0;.35;.6;.95;1" dur="3s" repeatCount="indefinite"/><animate attributeName="r" values="4;4;4;2;4" keyTimes="0;.35;.6;.95;1" dur="3s" repeatCount="indefinite"/></circle>
</svg>

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

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)

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 metadata is added: only in baseTranscript (model-request.ts ~127–135). The item.id === message.id guard 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 has metadata with Message.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 baseTranscript describes the actual behaviour.
  • Tests: I ran bun test test/session-runner.test.ts test/session-compaction.test.ts in packages/core and all 289 pass. I didn't rerun typecheck or bun run check.

I changed nothing. The bun.lock modification in the checkout isn't mine, and I left it in place.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rekram1-node
rekram1-node merged commit 2f886a2 into dev Nov 13, 2026
11 checks passed
@rekram1-node
rekram1-node deleted the context-hook-metadata branch November 13, 2026 01:01
swf-cmd added a commit to swf-cmd/opencode-upstream that referenced this pull request Nov 13, 2026
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
swf-cmd added a commit to swf-cmd/opencode-upstream that referenced this pull request Nov 13, 2026
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
Xipzer added a commit to Xipzer/kimaki that referenced this pull request Nov 13, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: context hook messages drop metadata, so per-message plugin context never reaches the model

1 participant