Skip to content

fix(opencode): capture current message events#2985

Closed
pixeru wants to merge 1 commit into
thedotmack:mainfrom
pixeru:main
Closed

fix(opencode): capture current message events#2985
pixeru wants to merge 1 commit into
thedotmack:mainfrom
pixeru:main

Conversation

@pixeru

@pixeru pixeru commented Jun 17, 2026

Copy link
Copy Markdown

When I was using OpenCode to use claude-mem, the hooks doesn't initialize, therefore leading to empty knowledge bank of session summaries. Turns out there's a problem: the plugin was still using the stale chat.message hook, and its worker POSTs were fire-and-forget, so session init and observation requests could be dropped.

Summary

Replace stale chat.message OpenCode capture path with current message.updated / message.part.updated bus events
Await worker POSTs for session init, observations, and summaries so capture requests are not dropped
Update OpenCode contract tests to guard against stale hook/event regressions

Verification

bun test tests/integrations/opencode-plugin-contract.test.ts tests/integration/opencode-installer.test.ts
npm run typecheck:root
npm run build

@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the OpenCode plugin's broken capture path by replacing the stale chat.message hook (not emitted by current OpenCode versions) with the message.updated / message.part.updated bus events, and converts all worker POSTs from fire-and-forget to awaited calls so session init, observations, and summaries are no longer silently dropped.

  • Removes chat.message hook, adds handling for message.updated (tracks message identity and completion) and message.part.updated (accumulates text fragments), with a deduplication guard in captureAssistantMessage to avoid double-posting the same assistant message.
  • Converts workerPostFireAndForget to an async workerPost and awaits it in all three capture paths (tool.execute.after, experimental.session.compacting, and the new event handler), preventing dropped requests.
  • Adds module-level Maps/Sets (assistantMessageSessionIds, completedAssistantMessageIds, capturedAssistantMessageIds, textPartsByMessageId) to coordinate the two-event capture flow, and updates contract tests to assert the new hook/event names.

Confidence Score: 3/5

The core mechanics work for the common streaming order (parts then finish), but the deduplication guard can produce incomplete observations when a message-complete event precedes its text parts.

The new two-event capture flow introduces a deduplication guard that marks a message captured on the first text part whose trigger condition fires after a message-complete signal. Any parts arriving after that first one are silently dropped — the observation records only a fragment of the assistant reply. The convert-to-await change is a real improvement and the common ordering is unaffected, but the edge case is a silent data-loss path with no logging or retry.

The capture coordination logic in src/integrations/opencode-plugin/index.ts around captureAssistantMessage, completedAssistantMessageIds, and capturedAssistantMessageIds needs the closest look.

Important Files Changed

Filename Overview
src/integrations/opencode-plugin/index.ts Replaces stale chat.message hook with message.updated/message.part.updated bus events and converts fire-and-forget POSTs to awaited calls; introduces a deduplication guard (capturedAssistantMessageIds) that can prematurely mark a message captured when message.updated(finish) arrives before all text parts, causing partial observations.
tests/integrations/opencode-plugin-contract.test.ts Updates contract tests to reflect new hook and event names; adds a new integration test for the message.updated/message.part.updated capture path, but relies on module-level singleton state that persists across test runs in the same process.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant OC as OpenCode
    participant P as Plugin (event handler)
    participant M as Module-level Maps
    participant W as Worker HTTP

    OC->>P: "message.updated { role: assistant, id: msg1 }"
    P->>M: rememberAssistantMessage(msg1, ses1)
    alt isAssistantMessageComplete
        P->>P: captureAssistantMessage(msg1) — no text yet, skip
        P->>M: completedAssistantMessageIds.add(msg1)
    end

    OC->>P: "message.part.updated { messageID: msg1, text, time.end }"
    P->>M: rememberTextPart(msg1, text)
    P->>M: assistantMessageSessionIds.has(msg1)?
    alt YES and (isTextPartComplete OR completedAssistantMessageIds)
        P->>P: captureAssistantMessage(msg1)
        P->>M: capturedAssistantMessageIds.add(msg1)
        P->>W: POST /api/sessions/init (if new session)
        P->>W: POST /api/sessions/observations
    end

    OC->>P: "tool.execute.after { tool, sessionID, args }"
    P->>W: POST /api/sessions/init (if new session)
    P->>W: POST /api/sessions/observations

    OC->>P: "experimental.session.compacting { sessionID }"
    P->>W: POST /api/sessions/summarize

    OC->>P: "event { type: session.idle, sessionID }"
    P->>W: POST /api/sessions/summarize

    OC->>P: "event { type: session.deleted, sessionID }"
    P->>M: Clean up all maps for sessionID
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant OC as OpenCode
    participant P as Plugin (event handler)
    participant M as Module-level Maps
    participant W as Worker HTTP

    OC->>P: "message.updated { role: assistant, id: msg1 }"
    P->>M: rememberAssistantMessage(msg1, ses1)
    alt isAssistantMessageComplete
        P->>P: captureAssistantMessage(msg1) — no text yet, skip
        P->>M: completedAssistantMessageIds.add(msg1)
    end

    OC->>P: "message.part.updated { messageID: msg1, text, time.end }"
    P->>M: rememberTextPart(msg1, text)
    P->>M: assistantMessageSessionIds.has(msg1)?
    alt YES and (isTextPartComplete OR completedAssistantMessageIds)
        P->>P: captureAssistantMessage(msg1)
        P->>M: capturedAssistantMessageIds.add(msg1)
        P->>W: POST /api/sessions/init (if new session)
        P->>W: POST /api/sessions/observations
    end

    OC->>P: "tool.execute.after { tool, sessionID, args }"
    P->>W: POST /api/sessions/init (if new session)
    P->>W: POST /api/sessions/observations

    OC->>P: "experimental.session.compacting { sessionID }"
    P->>W: POST /api/sessions/summarize

    OC->>P: "event { type: session.idle, sessionID }"
    P->>W: POST /api/sessions/summarize

    OC->>P: "event { type: session.deleted, sessionID }"
    P->>M: Clean up all maps for sessionID
Loading
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
src/integrations/opencode-plugin/index.ts:324-334
**Partial capture when message completes before all parts arrive**

When `message.updated` (with `finish`) fires and `captureAssistantMessage` is called with no text yet (returns early, not marked captured), any subsequent `message.part.updated` events check `completedAssistantMessageIds.has(part.messageID)` and trigger capture on the **first** arriving part — which immediately sets `capturedAssistantMessageIds.add(messageID)`. Any parts arriving after that first one are stored in `textPartsByMessageId` but skipped in `captureAssistantMessage` because the deduplication guard fires first, producing an incomplete observation with only the first fragment of the assistant message.

### Issue 2 of 4
src/integrations/opencode-plugin/index.ts:209-218
**`textPartsByMessageId` not bounded by `pruneAssistantMessageCache`**

`rememberTextPart` writes to `textPartsByMessageId` for every `message.part.updated` event, regardless of whether the message ID is tracked in `assistantMessageSessionIds`. Because `pruneAssistantMessageCache` only evicts entries that exist in `assistantMessageSessionIds`, parts for messages that never appear in a `message.updated` event (e.g., user messages that only emit part events, or parts for abandoned streams) accumulate in `textPartsByMessageId` without bound and are never pruned.

### Issue 3 of 4
src/integrations/opencode-plugin/index.ts:309
**`info?.id` is a message ID, not a session ID**

The fallback `event?.properties?.info?.id` reads from `OpenCodeMessageInfo.id`, which is the message identifier. For `session.idle` and `session.deleted` events, this field would represent a message ID, not a session ID. If `properties.sessionID` were ever absent on those events, the wrong ID would be used as the session key — silently failing to clean up or summarize the correct session.

### Issue 4 of 4
tests/integrations/opencode-plugin-contract.test.ts:123-172
**Module-level state causes flaky assertions on repeated test runs**

`assistantMessageSessionIds`, `capturedAssistantMessageIds`, `textPartsByMessageId`, and `initializedSessionIds` are module-level singletons shared across all test cases within the same process. If the test suite is run again (e.g., Bun watch mode or a second `bun test` invocation in the same process), `ses_msg_1` will already be in `initializedSessionIds` (no init POST sent) and `msg_1` will already be in `capturedAssistantMessageIds` (no observation POST sent), causing `expect(initPost).toBeTruthy()` and `expect(obsPost).toBeTruthy()` to fail.

Reviews (1): Last reviewed commit: "fix(opencode): capture current message e..." | Re-trigger Greptile

Comment on lines +324 to +334
case "message.part.updated": {
const part = event.properties?.part;
const messageSessionID = event.properties?.sessionID || part?.sessionID;
if (!part?.messageID || !messageSessionID || part.type !== "text") return;

rememberTextPart(part);
if (!assistantMessageSessionIds.has(part.messageID)) return;
if (isTextPartComplete(part) || completedAssistantMessageIds.has(part.messageID)) {
await captureAssistantMessage(part.messageID, messageSessionID, projectName, ctx.directory);
}
break;

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.

P1 Partial capture when message completes before all parts arrive

When message.updated (with finish) fires and captureAssistantMessage is called with no text yet (returns early, not marked captured), any subsequent message.part.updated events check completedAssistantMessageIds.has(part.messageID) and trigger capture on the first arriving part — which immediately sets capturedAssistantMessageIds.add(messageID). Any parts arriving after that first one are stored in textPartsByMessageId but skipped in captureAssistantMessage because the deduplication guard fires first, producing an incomplete observation with only the first fragment of the assistant message.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/integrations/opencode-plugin/index.ts
Line: 324-334

Comment:
**Partial capture when message completes before all parts arrive**

When `message.updated` (with `finish`) fires and `captureAssistantMessage` is called with no text yet (returns early, not marked captured), any subsequent `message.part.updated` events check `completedAssistantMessageIds.has(part.messageID)` and trigger capture on the **first** arriving part — which immediately sets `capturedAssistantMessageIds.add(messageID)`. Any parts arriving after that first one are stored in `textPartsByMessageId` but skipped in `captureAssistantMessage` because the deduplication guard fires first, producing an incomplete observation with only the first fragment of the assistant message.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +209 to +218
function pruneAssistantMessageCache(): void {
while (assistantMessageSessionIds.size > MAX_MESSAGE_CACHE_ENTRIES) {
const oldestMessageId = assistantMessageSessionIds.keys().next().value;
if (oldestMessageId === undefined) break;
assistantMessageSessionIds.delete(oldestMessageId);
completedAssistantMessageIds.delete(oldestMessageId);
capturedAssistantMessageIds.delete(oldestMessageId);
textPartsByMessageId.delete(oldestMessageId);
}
}

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.

P2 textPartsByMessageId not bounded by pruneAssistantMessageCache

rememberTextPart writes to textPartsByMessageId for every message.part.updated event, regardless of whether the message ID is tracked in assistantMessageSessionIds. Because pruneAssistantMessageCache only evicts entries that exist in assistantMessageSessionIds, parts for messages that never appear in a message.updated event (e.g., user messages that only emit part events, or parts for abandoned streams) accumulate in textPartsByMessageId without bound and are never pruned.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/integrations/opencode-plugin/index.ts
Line: 209-218

Comment:
**`textPartsByMessageId` not bounded by `pruneAssistantMessageCache`**

`rememberTextPart` writes to `textPartsByMessageId` for every `message.part.updated` event, regardless of whether the message ID is tracked in `assistantMessageSessionIds`. Because `pruneAssistantMessageCache` only evicts entries that exist in `assistantMessageSessionIds`, parts for messages that never appear in a `message.updated` event (e.g., user messages that only emit part events, or parts for abandoned streams) accumulate in `textPartsByMessageId` without bound and are never pruned.

How can I resolve this? If you propose a fix, please make it concise.

// text capture path (see REAL_OPENCODE_EVENT_TYPES).
event: async ({ event }: { event: BusEvent }): Promise<void> => {
const eventType = event?.type as RealOpenCodeEventType | undefined;
const sessionID = event?.properties?.sessionID || event?.properties?.info?.id;

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.

P2 info?.id is a message ID, not a session ID

The fallback event?.properties?.info?.id reads from OpenCodeMessageInfo.id, which is the message identifier. For session.idle and session.deleted events, this field would represent a message ID, not a session ID. If properties.sessionID were ever absent on those events, the wrong ID would be used as the session key — silently failing to clean up or summarize the correct session.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/integrations/opencode-plugin/index.ts
Line: 309

Comment:
**`info?.id` is a message ID, not a session ID**

The fallback `event?.properties?.info?.id` reads from `OpenCodeMessageInfo.id`, which is the message identifier. For `session.idle` and `session.deleted` events, this field would represent a message ID, not a session ID. If `properties.sessionID` were ever absent on those events, the wrong ID would be used as the session key — silently failing to clean up or summarize the correct session.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +123 to +172
it("posts assistant observations from message.updated and message.part.updated events", async () => {
const posts: Array<{ url: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
posts.push({
url: String(url),
body: init?.body ? JSON.parse(String(init.body)) : null,
});
return new Response(JSON.stringify({ status: "queued" }), { status: 200 });
}) as typeof fetch;

try {
const plugin = await ClaudeMemPlugin(pluginCtx);
await plugin.event({
event: {
type: "message.updated",
properties: {
sessionID: "ses_msg_1",
info: { id: "msg_1", role: "assistant", sessionID: "ses_msg_1" },
},
},
});
await plugin.event({
event: {
type: "message.part.updated",
properties: {
sessionID: "ses_msg_1",
part: {
id: "prt_1",
sessionID: "ses_msg_1",
messageID: "msg_1",
type: "text",
text: "assistant text",
time: { end: 1 },
},
},
},
});

const initPost = posts.find((p) => p.url.includes("/api/sessions/init"));
const obsPost = posts.find((p) => p.url.includes("/api/sessions/observations"));
expect(initPost, "message events should lazily init the session").toBeTruthy();
expect(obsPost, "message events should POST an assistant observation").toBeTruthy();
const obsBody = obsPost!.body as Record<string, unknown>;
expect(obsBody.tool_name).toBe("assistant_message");
expect(obsBody.tool_response).toBe("assistant text");
} finally {
globalThis.fetch = originalFetch;
}
});

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.

P2 Module-level state causes flaky assertions on repeated test runs

assistantMessageSessionIds, capturedAssistantMessageIds, textPartsByMessageId, and initializedSessionIds are module-level singletons shared across all test cases within the same process. If the test suite is run again (e.g., Bun watch mode or a second bun test invocation in the same process), ses_msg_1 will already be in initializedSessionIds (no init POST sent) and msg_1 will already be in capturedAssistantMessageIds (no observation POST sent), causing expect(initPost).toBeTruthy() and expect(obsPost).toBeTruthy() to fail.

Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/integrations/opencode-plugin-contract.test.ts
Line: 123-172

Comment:
**Module-level state causes flaky assertions on repeated test runs**

`assistantMessageSessionIds`, `capturedAssistantMessageIds`, `textPartsByMessageId`, and `initializedSessionIds` are module-level singletons shared across all test cases within the same process. If the test suite is run again (e.g., Bun watch mode or a second `bun test` invocation in the same process), `ses_msg_1` will already be in `initializedSessionIds` (no init POST sent) and `msg_1` will already be in `capturedAssistantMessageIds` (no observation POST sent), causing `expect(initPost).toBeTruthy()` and `expect(obsPost).toBeTruthy()` to fail.

How can I resolve this? If you propose a fix, please make it concise.

@pixeru pixeru closed this by deleting the head repository Jul 3, 2026
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.

1 participant