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>
This commit is contained in:
paseo-bot[bot]
2026-09-26 13:24:18 +02:00
committed by GitHub
parent 26f227bed9
commit a272a22d7e
3 changed files with 121 additions and 2 deletions
@@ -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: {
@@ -1318,7 +1318,10 @@ function buildOpenCodeReplayTimelineEvents(
}
if (info.role === "user") {
const text = parts
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
.filter(
(part): part is Extract<OpenCodePart, { type: "text" }> =>
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<OpenCodeEvent, { type: "message.part.updated" }>["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);
@@ -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();