From 09a1fe46fee9ccafd74f453b2f95a227387536da Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 8 Jun 2026 10:14:54 +0700 Subject: [PATCH] Hide OpenCode compaction summaries --- .../agent/providers/opencode-agent.test.ts | 244 ++++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 102 +++++++- .../opencode/event-translator.test.ts | 103 ++++++++ .../test-utils/test-opencode-runtime.ts | 6 +- 4 files changed, 446 insertions(+), 9 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 cca4a04e2..235f975dd 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -112,6 +112,66 @@ function assistantTurnEvents({ ]; } +function manualCompactEvents({ + sessionId = "session-1", + summaryText = "## Goal\n- Preserve context while continuing the task.", +}: { + sessionId?: string; + summaryText?: string; +} = {}): OpenCodeEvent[] { + return [ + { + type: "message.updated", + properties: { + info: { + id: "msg_compact_user", + sessionID: sessionId, + role: "user", + }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "prt_compact", + sessionID: sessionId, + messageID: "msg_compact_user", + type: "compaction", + auto: false, + }, + }, + }, + { + type: "message.updated", + properties: { + info: { + id: "msg_compact_summary", + sessionID: sessionId, + role: "assistant", + providerID: "test-provider", + modelID: "gpt-5.5", + }, + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "prt_compact_summary", + sessionID: sessionId, + messageID: "msg_compact_summary", + type: "text", + text: summaryText, + time: { start: 1, end: 2 }, + }, + }, + }, + { type: "session.compacted", properties: { sessionID: sessionId } }, + { type: "session.idle", properties: { sessionID: sessionId } }, + ]; +} + describe("OpenCodeAgentClient adapter smoke tests", () => { const logger = createTestLogger(); const buildConfig = (cwd: string): AgentSessionConfig => ({ @@ -168,6 +228,41 @@ describe("OpenCodeAgentClient adapter smoke tests", () => { rmSync(cwd, { recursive: true, force: true }); }, 120_000); + test("manual compact hides the generated summary text", async () => { + const cwd = tmpCwd(); + const runtime = new TestOpenCodeRuntime(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionSummarizeEvents = manualCompactEvents(); + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(logger, undefined, { runtime }); + const session = await client.createSession({ + provider: "opencode", + cwd, + model: "test-provider/gpt-5.5", + }); + + const turn = await collectTurnEvents(streamSession(session, "/compact")); + + expect(turn.turnCompleted).toBe(true); + expect(turn.assistantMessages).toEqual([]); + expect(turn.allTimelineItems).toEqual([ + { type: "user_message", text: "/compact", messageId: "msg_compact_user" }, + { type: "compaction", status: "loading", trigger: "manual" }, + { type: "compaction", status: "completed" }, + ]); + expect(openCodeClient.calls.sessionSummarize).toEqual([ + expect.objectContaining({ + sessionID: "session-1", + directory: cwd, + providerID: "test-provider", + modelID: "gpt-5.5", + }), + ]); + + await session.close(); + rmSync(cwd, { recursive: true, force: true }); + }, 120_000); + test("listModels returns models with required fields", async () => { const runtime = new TestOpenCodeRuntime(); const openCodeClient = new TestOpenCodeClient(); @@ -1595,6 +1690,155 @@ describe("OpenCodeAgentClient env", () => { }); describe("OpenCode persisted sessions", () => { + test("replay hides summaries produced by manual compact", () => { + const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([ + { + info: { + id: "msg_compact_user", + sessionID: "ses_1", + role: "user", + time: { created: 1000 }, + agent: "build", + model: { providerID: "test-provider", modelID: "gpt-5.5" }, + }, + parts: [ + { + id: "prt_compact_text", + sessionID: "ses_1", + messageID: "msg_compact_user", + type: "text", + text: "/compact", + }, + { + id: "prt_compact", + sessionID: "ses_1", + messageID: "msg_compact_user", + type: "compaction", + auto: false, + }, + ], + }, + { + info: { + id: "msg_compact_summary", + sessionID: "ses_1", + role: "assistant", + time: { created: 1001, completed: 1002 }, + providerID: "test-provider", + modelID: "gpt-5.5", + }, + parts: [ + { + id: "prt_summary", + sessionID: "ses_1", + messageID: "msg_compact_summary", + type: "text", + text: "## Goal\n- Preserve context while continuing the task.", + }, + ], + }, + { + info: { + id: "msg_next_user", + sessionID: "ses_1", + role: "user", + time: { created: 1003 }, + agent: "build", + model: { providerID: "test-provider", modelID: "gpt-5.5" }, + }, + parts: [ + { + id: "prt_next_user", + sessionID: "ses_1", + messageID: "msg_next_user", + type: "text", + text: "continue", + }, + ], + }, + ]); + + expect(timeline).toEqual([ + { type: "user_message", text: "/compact", messageId: "msg_compact_user" }, + { type: "compaction", status: "completed", trigger: "manual" }, + { type: "user_message", text: "continue", messageId: "msg_next_user" }, + ]); + }); + + test("replay suppresses OpenCode compaction summary messages", () => { + const timeline = __openCodeInternals.buildOpenCodeSessionTimeline([ + { + info: { + id: "msg_compaction_user", + sessionID: "ses_1", + role: "user", + time: { created: 1000 }, + agent: "build", + model: { providerID: "opencode", modelID: "big-pickle" }, + }, + parts: [ + { + id: "prt_compaction", + sessionID: "ses_1", + messageID: "msg_compaction_user", + type: "compaction", + auto: true, + }, + ], + }, + { + info: { + id: "msg_compaction_summary", + sessionID: "ses_1", + role: "assistant", + time: { created: 1001, completed: 1002 }, + parentID: "msg_compaction_user", + providerID: "opencode", + modelID: "big-pickle", + mode: "compaction", + agent: "compaction", + path: { cwd: "/workspace/repo", root: "/workspace/repo" }, + summary: true, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + { + id: "prt_summary", + sessionID: "ses_1", + messageID: "msg_compaction_summary", + type: "text", + text: "## Goal\n- Preserve context while continuing the task.", + }, + ], + }, + { + info: { + id: "msg_user_after_compaction", + sessionID: "ses_1", + role: "user", + time: { created: 1003 }, + agent: "build", + model: { providerID: "opencode", modelID: "big-pickle" }, + }, + parts: [ + { + id: "prt_user_after_compaction", + sessionID: "ses_1", + messageID: "msg_user_after_compaction", + type: "text", + text: "/create-pr", + }, + ], + }, + ]); + + expect(timeline).toEqual([ + { type: "compaction", status: "completed", trigger: "auto" }, + { type: "user_message", text: "/create-pr", messageId: "msg_user_after_compaction" }, + ]); + }); + test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => { const runtime = new TestOpenCodeRuntime(); const openCodeClient = new TestOpenCodeClient(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 798b9fa05..c5e440a51 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1040,6 +1040,21 @@ function buildOpenCodeReplayPartTimelineEvent(params: { }); } +function isOpenCodeCompactionSummaryMessage(message: OpenCodeMessage): boolean { + return ( + message.role === "assistant" && + (message.summary === true || message.agent === "compaction" || message.mode === "compaction") + ); +} + +function findOpenCodeCompactionPart( + message: OpenCodeSessionMessage, +): Extract | undefined { + return message.parts.find( + (part): part is Extract => part.type === "compaction", + ); +} + async function readOpenCodeSessionMessagesFromSdk( client: Pick, session: OpenCodePersistedSession, @@ -1059,9 +1074,30 @@ async function readOpenCodeSessionMessagesFromSdk( function buildOpenCodeSessionTimeline( messages: ReadonlyArray, ): AgentTimelineItem[] { - return messages.flatMap((message) => - buildOpenCodeReplayTimelineEvents(message).map((event) => event.item), - ); + const timeline: AgentTimelineItem[] = []; + let hideNextAssistantAfterCompaction = false; + + for (const message of messages) { + const compactionPart = findOpenCodeCompactionPart(message); + if (message.info.role === "assistant" && hideNextAssistantAfterCompaction) { + hideNextAssistantAfterCompaction = false; + continue; + } + if (message.info.role === "user" && !compactionPart) { + hideNextAssistantAfterCompaction = false; + } + + timeline.push(...buildOpenCodeReplayTimelineEvents(message).map((event) => event.item)); + + if (message.info.role === "user" && compactionPart) { + timeline.push( + createCompactionTimelineItem("completed", compactionPart.auto ? "auto" : "manual"), + ); + hideNextAssistantAfterCompaction = true; + } + } + + return timeline; } function filterOpenCodeRevertedMessages( @@ -1120,6 +1156,9 @@ function buildOpenCodeReplayTimelineEvents( message: OpenCodeSessionMessage, ): Extract[] { const { info, parts } = message; + if (isOpenCodeCompactionSummaryMessage(info)) { + return []; + } if (info.role === "user") { const text = parts .filter((part): part is Extract => part.type === "text") @@ -1165,6 +1204,7 @@ function buildOpenCodeReplayTimelineEvents( export const __openCodeInternals = { buildOpenCodePromptParts, + buildOpenCodeSessionTimeline, buildOpenCodeModelContextWindowLookup, buildOpenCodeModelDefinition, buildOpenCodeModelLookupKey, @@ -1591,6 +1631,9 @@ export interface OpenCodeEventTranslationState { sessionTotalCostUsd?: number; streamedPartKeys: Set; emittedStructuredMessageIds: Set; + compactionSummaryMessageIds: Set; + emittedCompactionPartIds: Set; + suppressAssistantMessagesUntilIdle?: { active: boolean }; /** Tracks the type of each part by ID, learned from message.part.updated events. */ partTypes: Map; subAgentsByCallId?: Map; @@ -1951,6 +1994,11 @@ export function translateOpenCodeEvent( function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): void { state.streamedPartKeys.clear(); state.partTypes.clear(); + state.compactionSummaryMessageIds.clear(); + state.emittedCompactionPartIds.clear(); + if (state.suppressAssistantMessagesUntilIdle) { + state.suppressAssistantMessagesUntilIdle.active = false; + } } function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): { @@ -2263,6 +2311,14 @@ function appendOpenCodeMessageUpdated( if (info.role !== "assistant") { return; } + if (state.suppressAssistantMessagesUntilIdle?.active) { + state.compactionSummaryMessageIds.add(info.id); + return; + } + if (isOpenCodeCompactionSummaryMessage(info)) { + state.compactionSummaryMessageIds.add(info.id); + return; + } const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info); if (modelLookupKey) { const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); @@ -2320,6 +2376,15 @@ function appendOpenCodeMessagePartUpdated( const messageRole = state.messageRoles.get(part.messageID); state.partTypes.set(part.id, part.type); + if (state.compactionSummaryMessageIds.has(part.messageID)) { + return; + } + + if (shouldSuppressOpenCodeAssistantPart(part, messageRole, state)) { + state.compactionSummaryMessageIds.add(part.messageID); + return; + } + if (part.type === "text") { appendOpenCodeTextPart(part, messageRole, state, events); return; @@ -2336,6 +2401,10 @@ function appendOpenCodeMessagePartUpdated( return; } if (part.type === "compaction") { + if (state.emittedCompactionPartIds.has(part.id)) { + return; + } + state.emittedCompactionPartIds.add(part.id); events.push({ type: "timeline", provider: "opencode", @@ -2361,6 +2430,18 @@ function appendOpenCodeMessagePartUpdated( } } +function shouldSuppressOpenCodeAssistantPart( + part: Extract["properties"]["part"], + messageRole: OpenCodeMessageRole | undefined, + state: OpenCodeEventTranslationState, +): boolean { + return ( + state.suppressAssistantMessagesUntilIdle?.active === true && + part.type === "text" && + messageRole !== "user" + ); +} + function appendOpenCodeTextPart( part: Extract< Extract["properties"]["part"], @@ -2691,6 +2772,9 @@ class OpenCodeAgentSession implements AgentSession { private streamedPartKeys = new Set(); /** Tracks assistant messages already emitted from structured payloads. */ private emittedStructuredMessageIds = new Set(); + private compactionSummaryMessageIds = new Set(); + private emittedCompactionPartIds = new Set(); + private suppressAssistantMessagesUntilIdle = { active: false }; /** Tracks the type of each part by ID, learned from message.part.updated events. */ private partTypes = new Map(); private availableModesCache: AgentMode[] | null = null; @@ -2874,6 +2958,7 @@ class OpenCodeAgentSession implements AgentSession { const parts = buildOpenCodePromptParts(prompt); this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt); + this.suppressAssistantMessagesUntilIdle.active = false; const model = this.parseModel(this.config.model); const thinkingOptionId = this.config.thinkingOptionId; const effectiveVariant = thinkingOptionId ?? undefined; @@ -2895,6 +2980,7 @@ class OpenCodeAgentSession implements AgentSession { const slashCommand = await this.resolveSlashCommandInvocation(prompt); if (slashCommand) { if (slashCommand.commandName === "compact" || slashCommand.commandName === "summarize") { + this.suppressAssistantMessagesUntilIdle.active = true; void this.client.session .summarize({ sessionID: this.sessionId, @@ -2903,6 +2989,7 @@ class OpenCodeAgentSession implements AgentSession { }) .then((response) => { if (response.error) { + this.suppressAssistantMessagesUntilIdle.active = false; this.finishForegroundTurn( { type: "turn_failed", @@ -2911,15 +2998,11 @@ class OpenCodeAgentSession implements AgentSession { }, turnId, ); - } else { - this.finishForegroundTurn( - { type: "turn_completed", provider: "opencode", usage: undefined }, - turnId, - ); } return; }) .catch((error) => { + this.suppressAssistantMessagesUntilIdle.active = false; this.finishForegroundTurn( { type: "turn_failed", @@ -3640,6 +3723,9 @@ class OpenCodeAgentSession implements AgentSession { sessionTotalCostUsd: this.sessionTotalCostUsd, streamedPartKeys: this.streamedPartKeys, emittedStructuredMessageIds: this.emittedStructuredMessageIds, + compactionSummaryMessageIds: this.compactionSummaryMessageIds, + emittedCompactionPartIds: this.emittedCompactionPartIds, + suppressAssistantMessagesUntilIdle: this.suppressAssistantMessagesUntilIdle, partTypes: this.partTypes, subAgentsByCallId: this.subAgentsByCallId, subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId, 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 ca3e4e49d..6c42962f3 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 @@ -31,6 +31,8 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState { accumulatedUsage: {}, streamedPartKeys: new Set(), emittedStructuredMessageIds: new Set(), + compactionSummaryMessageIds: new Set(), + emittedCompactionPartIds: new Set(), partTypes: new Map(), }; } @@ -801,6 +803,107 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("does not render OpenCode compaction summaries as assistant messages", () => { + const state = createState(); + + const loading = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "compaction-part-1", + sessionID: "session-1", + messageID: "message-compaction-user-1", + type: "compaction", + auto: true, + }, + }, + }, + state, + ); + + translateOpenCodeEvent( + { + type: "message.updated", + properties: { + info: { + id: "message-compaction-summary-1", + sessionID: "session-1", + role: "assistant", + mode: "compaction", + agent: "compaction", + summary: true, + }, + }, + }, + state, + ); + + const summaryText = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "summary-text-part-1", + sessionID: "session-1", + messageID: "message-compaction-summary-1", + type: "text", + text: "## Goal\n- Preserve context while continuing the task.", + time: { start: 1, end: 2 }, + }, + }, + }, + state, + ); + + const duplicateLoading = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "compaction-part-1", + sessionID: "session-1", + messageID: "message-compaction-user-1", + type: "compaction", + auto: true, + tail_start_id: "message-tail-1", + }, + }, + }, + state, + ); + + const completed = translateOpenCodeEvent( + { + type: "session.compacted", + properties: { + sessionID: "session-1", + }, + }, + state, + ); + + expect([...loading, ...summaryText, ...duplicateLoading, ...completed]).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "loading", + trigger: "auto", + }, + }, + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "completed", + }, + }, + ]); + }); + it("emits compaction completed timeline items from session.compacted", () => { const state = createState(); diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts index 2f0060b70..eb7768f74 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts @@ -1,4 +1,4 @@ -import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"; +import type { Event as OpenCodeEvent, OpencodeClient } from "@opencode-ai/sdk/v2/client"; import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js"; @@ -92,6 +92,7 @@ export class TestOpenCodeClient { sessionMessagesResponse: OpenCodeResponse = { data: [] }; sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncResponse: OpenCodeResponse = {}; + sessionSummarizeEvents: OpenCodeEvent[] = []; sessionSummarizeResponse: OpenCodeResponse = { data: {} }; sessionUpdateResponse: OpenCodeResponse = {}; private readonly queuedEventStream = createQueuedEventStream(); @@ -208,6 +209,9 @@ export class TestOpenCodeClient { }, summarize: async (parameters: unknown) => { this.calls.sessionSummarize.push(parameters); + for (const event of this.sessionSummarizeEvents) { + this.emitEvent(event); + } return this.sessionSummarizeResponse; }, update: async (parameters: unknown) => {