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", () => { describe("OpenCodeAgentClient adapter smoke tests", () => {
const logger = createTestLogger(); const logger = createTestLogger();
const buildConfig = (cwd: string): AgentSessionConfig => ({ const buildConfig = (cwd: string): AgentSessionConfig => ({
@@ -168,6 +228,41 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}, 120_000); }, 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 () => { test("listModels returns models with required fields", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient(); const openCodeClient = new TestOpenCodeClient();
@@ -1595,6 +1690,155 @@ describe("OpenCodeAgentClient env", () => {
}); });
describe("OpenCode persisted sessions", () => { 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 () => { test("listPersistedAgents returns only sessions whose cwd matches the requested cwd", async () => {
const runtime = new TestOpenCodeRuntime(); const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient(); 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( async function readOpenCodeSessionMessagesFromSdk(
client: Pick<OpencodeClient, "session">, client: Pick<OpencodeClient, "session">,
session: OpenCodePersistedSession, session: OpenCodePersistedSession,
@@ -1059,9 +1074,30 @@ async function readOpenCodeSessionMessagesFromSdk(
function buildOpenCodeSessionTimeline( function buildOpenCodeSessionTimeline(
messages: ReadonlyArray<OpenCodeSessionMessage>, messages: ReadonlyArray<OpenCodeSessionMessage>,
): AgentTimelineItem[] { ): AgentTimelineItem[] {
return messages.flatMap((message) => const timeline: AgentTimelineItem[] = [];
buildOpenCodeReplayTimelineEvents(message).map((event) => event.item), 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( function filterOpenCodeRevertedMessages(
@@ -1120,6 +1156,9 @@ function buildOpenCodeReplayTimelineEvents(
message: OpenCodeSessionMessage, message: OpenCodeSessionMessage,
): Extract<AgentStreamEvent, { type: "timeline" }>[] { ): Extract<AgentStreamEvent, { type: "timeline" }>[] {
const { info, parts } = message; const { info, parts } = message;
if (isOpenCodeCompactionSummaryMessage(info)) {
return [];
}
if (info.role === "user") { if (info.role === "user") {
const text = parts 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")
@@ -1165,6 +1204,7 @@ function buildOpenCodeReplayTimelineEvents(
export const __openCodeInternals = { export const __openCodeInternals = {
buildOpenCodePromptParts, buildOpenCodePromptParts,
buildOpenCodeSessionTimeline,
buildOpenCodeModelContextWindowLookup, buildOpenCodeModelContextWindowLookup,
buildOpenCodeModelDefinition, buildOpenCodeModelDefinition,
buildOpenCodeModelLookupKey, buildOpenCodeModelLookupKey,
@@ -1591,6 +1631,9 @@ export interface OpenCodeEventTranslationState {
sessionTotalCostUsd?: number; sessionTotalCostUsd?: number;
streamedPartKeys: Set<string>; streamedPartKeys: Set<string>;
emittedStructuredMessageIds: 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. */ /** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>; partTypes: Map<string, string>;
subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>; subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>;
@@ -1951,6 +1994,11 @@ export function translateOpenCodeEvent(
function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): void { function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): void {
state.streamedPartKeys.clear(); state.streamedPartKeys.clear();
state.partTypes.clear(); state.partTypes.clear();
state.compactionSummaryMessageIds.clear();
state.emittedCompactionPartIds.clear();
if (state.suppressAssistantMessagesUntilIdle) {
state.suppressAssistantMessagesUntilIdle.active = false;
}
} }
function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): { function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): {
@@ -2263,6 +2311,14 @@ function appendOpenCodeMessageUpdated(
if (info.role !== "assistant") { if (info.role !== "assistant") {
return; return;
} }
if (state.suppressAssistantMessagesUntilIdle?.active) {
state.compactionSummaryMessageIds.add(info.id);
return;
}
if (isOpenCodeCompactionSummaryMessage(info)) {
state.compactionSummaryMessageIds.add(info.id);
return;
}
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info); const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
if (modelLookupKey) { if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
@@ -2320,6 +2376,15 @@ function appendOpenCodeMessagePartUpdated(
const messageRole = state.messageRoles.get(part.messageID); const messageRole = state.messageRoles.get(part.messageID);
state.partTypes.set(part.id, part.type); 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") { if (part.type === "text") {
appendOpenCodeTextPart(part, messageRole, state, events); appendOpenCodeTextPart(part, messageRole, state, events);
return; return;
@@ -2336,6 +2401,10 @@ function appendOpenCodeMessagePartUpdated(
return; return;
} }
if (part.type === "compaction") { if (part.type === "compaction") {
if (state.emittedCompactionPartIds.has(part.id)) {
return;
}
state.emittedCompactionPartIds.add(part.id);
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "opencode", 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( function appendOpenCodeTextPart(
part: Extract< part: Extract<
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"], Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
@@ -2691,6 +2772,9 @@ class OpenCodeAgentSession implements AgentSession {
private streamedPartKeys = new Set<string>(); private streamedPartKeys = new Set<string>();
/** Tracks assistant messages already emitted from structured payloads. */ /** Tracks assistant messages already emitted from structured payloads. */
private emittedStructuredMessageIds = new Set<string>(); 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. */ /** Tracks the type of each part by ID, learned from message.part.updated events. */
private partTypes = new Map<string, string>(); private partTypes = new Map<string, string>();
private availableModesCache: AgentMode[] | null = null; private availableModesCache: AgentMode[] | null = null;
@@ -2874,6 +2958,7 @@ class OpenCodeAgentSession implements AgentSession {
const parts = buildOpenCodePromptParts(prompt); const parts = buildOpenCodePromptParts(prompt);
this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt); this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt);
this.suppressAssistantMessagesUntilIdle.active = false;
const model = this.parseModel(this.config.model); const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId; const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant = thinkingOptionId ?? undefined; const effectiveVariant = thinkingOptionId ?? undefined;
@@ -2895,6 +2980,7 @@ class OpenCodeAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt); const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) { if (slashCommand) {
if (slashCommand.commandName === "compact" || slashCommand.commandName === "summarize") { if (slashCommand.commandName === "compact" || slashCommand.commandName === "summarize") {
this.suppressAssistantMessagesUntilIdle.active = true;
void this.client.session void this.client.session
.summarize({ .summarize({
sessionID: this.sessionId, sessionID: this.sessionId,
@@ -2903,6 +2989,7 @@ class OpenCodeAgentSession implements AgentSession {
}) })
.then((response) => { .then((response) => {
if (response.error) { if (response.error) {
this.suppressAssistantMessagesUntilIdle.active = false;
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
type: "turn_failed", type: "turn_failed",
@@ -2911,15 +2998,11 @@ class OpenCodeAgentSession implements AgentSession {
}, },
turnId, turnId,
); );
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
} }
return; return;
}) })
.catch((error) => { .catch((error) => {
this.suppressAssistantMessagesUntilIdle.active = false;
this.finishForegroundTurn( this.finishForegroundTurn(
{ {
type: "turn_failed", type: "turn_failed",
@@ -3640,6 +3723,9 @@ class OpenCodeAgentSession implements AgentSession {
sessionTotalCostUsd: this.sessionTotalCostUsd, sessionTotalCostUsd: this.sessionTotalCostUsd,
streamedPartKeys: this.streamedPartKeys, streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds, emittedStructuredMessageIds: this.emittedStructuredMessageIds,
compactionSummaryMessageIds: this.compactionSummaryMessageIds,
emittedCompactionPartIds: this.emittedCompactionPartIds,
suppressAssistantMessagesUntilIdle: this.suppressAssistantMessagesUntilIdle,
partTypes: this.partTypes, partTypes: this.partTypes,
subAgentsByCallId: this.subAgentsByCallId, subAgentsByCallId: this.subAgentsByCallId,
subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId, subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId,
@@ -31,6 +31,8 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
accumulatedUsage: {}, accumulatedUsage: {},
streamedPartKeys: new Set(), streamedPartKeys: new Set(),
emittedStructuredMessageIds: new Set(), emittedStructuredMessageIds: new Set(),
compactionSummaryMessageIds: new Set(),
emittedCompactionPartIds: new Set(),
partTypes: new Map(), 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", () => { it("emits compaction completed timeline items from session.compacted", () => {
const state = createState(); 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"; import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
@@ -92,6 +92,7 @@ export class TestOpenCodeClient {
sessionMessagesResponse: OpenCodeResponse = { data: [] }; sessionMessagesResponse: OpenCodeResponse = { data: [] };
sessionPromptAsyncEvents: unknown[] = [idleEvent()]; sessionPromptAsyncEvents: unknown[] = [idleEvent()];
sessionPromptAsyncResponse: OpenCodeResponse = {}; sessionPromptAsyncResponse: OpenCodeResponse = {};
sessionSummarizeEvents: OpenCodeEvent[] = [];
sessionSummarizeResponse: OpenCodeResponse = { data: {} }; sessionSummarizeResponse: OpenCodeResponse = { data: {} };
sessionUpdateResponse: OpenCodeResponse = {}; sessionUpdateResponse: OpenCodeResponse = {};
private readonly queuedEventStream = createQueuedEventStream(); private readonly queuedEventStream = createQueuedEventStream();
@@ -208,6 +209,9 @@ export class TestOpenCodeClient {
}, },
summarize: async (parameters: unknown) => { summarize: async (parameters: unknown) => {
this.calls.sessionSummarize.push(parameters); this.calls.sessionSummarize.push(parameters);
for (const event of this.sessionSummarizeEvents) {
this.emitEvent(event);
}
return this.sessionSummarizeResponse; return this.sessionSummarizeResponse;
}, },
update: async (parameters: unknown) => { update: async (parameters: unknown) => {