From a272a22d7ede22bee46a7a13fc852237548e4f37 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:24:18 +0200 Subject: [PATCH] fix(opencode): hide user messages OpenCode marks synthetic (#5434) OpenCode adds text to user messages on its own behalf (continue prompts after compaction or a subtask, MCP resource contents, plugin prompts) and flags that text `synthetic: true`. Its own UI hides it. Paseo built user bubbles from every text part, so reopening an OpenCode chat showed those messages as if the user had typed them, and appended synthetic text to the user's own message. Closes #5425 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../agent/providers/opencode-agent.test.ts | 69 +++++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 16 ++++- .../opencode/event-translator.test.ts | 38 ++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index fd36a75cb9..1ba8b282a9 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1944,6 +1944,75 @@ describe("OpenCode adapter startTurn error handling", () => { ]); }); + test("streamHistory hides user text that OpenCode marks synthetic", async () => { + const fakeClient = { + session: { + get: vi.fn().mockResolvedValue({ + data: { revert: undefined }, + error: undefined, + }), + messages: vi.fn().mockResolvedValue({ + data: [ + { + info: { id: "msg_continue", sessionID: "ses_unit_test", role: "user" }, + parts: [ + { + id: "prt_continue", + sessionID: "ses_unit_test", + messageID: "msg_continue", + type: "text", + text: "Summarize the task tool output above and continue with your task.", + synthetic: true, + }, + ], + }, + { + info: { id: "msg_user", sessionID: "ses_unit_test", role: "user" }, + parts: [ + { + id: "prt_user", + sessionID: "ses_unit_test", + messageID: "msg_user", + type: "text", + text: "Read the notes", + }, + { + id: "prt_resource", + sessionID: "ses_unit_test", + messageID: "msg_user", + type: "text", + text: "Reading MCP resource: notes.md", + synthetic: true, + }, + ], + }, + ], + error: undefined, + }), + }, + } as never; + + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_unit_test", + createTestLogger(), + ); + + const history: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + history.push(event); + } + + expect(history).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "user_message", text: "Read the notes", messageId: "msg_user" }, + }, + ]); + }); + test("streamHistory omits replay timestamps when OpenCode omits times", async () => { const fakeClient = { session: { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 362f2a7442..2276e85786 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1318,7 +1318,10 @@ function buildOpenCodeReplayTimelineEvents( } if (info.role === "user") { const text = parts - .filter((part): part is Extract => part.type === "text") + .filter( + (part): part is Extract => + part.type === "text" && isUserAuthoredOpenCodeText(part), + ) .map((part) => part.text) .join(""); @@ -2854,6 +2857,11 @@ function shouldSuppressOpenCodeAssistantPart( ); } +// OpenCode marks text it adds to a user message itself with `synthetic` and hides it from its own UI. +function isUserAuthoredOpenCodeText(part: { synthetic?: boolean }): boolean { + return part.synthetic !== true; +} + function appendOpenCodeTextPart( part: Extract< Extract["properties"]["part"], @@ -2864,7 +2872,11 @@ function appendOpenCodeTextPart( events: AgentStreamEvent[], ): void { if (messageRole === "user") { - if (!part.text || state.emittedUserMessageIds?.has(part.messageID)) { + if ( + !part.text || + !isUserAuthoredOpenCodeText(part) || + state.emittedUserMessageIds?.has(part.messageID) + ) { return; } state.emittedUserMessageIds?.add(part.messageID); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 50a3a9edfe..69977f1ab9 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -1276,6 +1276,44 @@ describe("translateOpenCodeEvent", () => { expect(result).toEqual([]); }); + it("shows OpenCode user text parts unless OpenCode marks them synthetic", () => { + const state = createState(); + const userTextPart = (messageId: string, text: string, synthetic: boolean) => { + translateOpenCodeEvent( + { + type: "message.updated", + properties: { info: { id: messageId, sessionID: "session-1", role: "user" } }, + }, + state, + ); + return translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: `part-${messageId}`, + sessionID: "session-1", + messageID: messageId, + type: "text", + text, + synthetic, + }, + }, + }, + state, + ); + }; + + expect(userTextPart("msg-continue", "Continue if you have next steps", true)).toEqual([]); + expect(userTextPart("msg-user", "Plugin prompt", false)).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { type: "user_message", text: "Plugin prompt", messageId: "msg-user" }, + }, + ]); + }); + it("ignores message.part.delta for user messages", () => { const state = createState();