Hide OpenCode compaction summaries

This commit is contained in:
Mohamed Boudra
2026-06-08 10:14:54 +07:00
parent 06a8f952db
commit 09a1fe46fe
4 changed files with 446 additions and 9 deletions
@@ -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();
@@ -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<OpenCodePart, { type: "compaction" }> | undefined {
return message.parts.find(
(part): part is Extract<OpenCodePart, { type: "compaction" }> => part.type === "compaction",
);
}
async function readOpenCodeSessionMessagesFromSdk(
client: Pick<OpencodeClient, "session">,
session: OpenCodePersistedSession,
@@ -1059,9 +1074,30 @@ async function readOpenCodeSessionMessagesFromSdk(
function buildOpenCodeSessionTimeline(
messages: ReadonlyArray<OpenCodeSessionMessage>,
): 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<AgentStreamEvent, { type: "timeline" }>[] {
const { info, parts } = message;
if (isOpenCodeCompactionSummaryMessage(info)) {
return [];
}
if (info.role === "user") {
const text = parts
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => 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<string>;
emittedStructuredMessageIds: Set<string>;
compactionSummaryMessageIds: Set<string>;
emittedCompactionPartIds: Set<string>;
suppressAssistantMessagesUntilIdle?: { active: boolean };
/** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>;
subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>;
@@ -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<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
messageRole: OpenCodeMessageRole | undefined,
state: OpenCodeEventTranslationState,
): boolean {
return (
state.suppressAssistantMessagesUntilIdle?.active === true &&
part.type === "text" &&
messageRole !== "user"
);
}
function appendOpenCodeTextPart(
part: Extract<
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
@@ -2691,6 +2772,9 @@ class OpenCodeAgentSession implements AgentSession {
private streamedPartKeys = new Set<string>();
/** Tracks assistant messages already emitted from structured payloads. */
private emittedStructuredMessageIds = new Set<string>();
private compactionSummaryMessageIds = new Set<string>();
private emittedCompactionPartIds = new Set<string>();
private suppressAssistantMessagesUntilIdle = { active: false };
/** Tracks the type of each part by ID, learned from message.part.updated events. */
private partTypes = new Map<string, string>();
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,
@@ -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();
@@ -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) => {