fix(opencode): verify persisted compaction results (#1357)

This commit is contained in:
Junmo Kim
2026-08-04 11:15:42 +08:00
committed by GitHub
parent 021b5c194b
commit c3bed919b8
5 changed files with 647 additions and 405 deletions
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { fetchCompactionSummary, splitProviderModel, triggerOpencodeCompact } from './opencodeCompactBridge';
import { captureCompactionMarkerSnapshot, fetchCompactionResult, splitProviderModel, triggerOpencodeCompact } from './opencodeCompactBridge';
// `signal` is a required field (see OpencodeCompactCallOpts's doc comment) —
// most tests below don't exercise abort behavior at all, so this is a
@@ -177,242 +177,179 @@ describe('triggerOpencodeCompact', () => {
});
});
describe('fetchCompactionSummary', () => {
it('extracts the text part of the assistant message that follows the compaction marker (matched via parentID)', async () => {
const fetchImpl = vi.fn(async (url: string) => {
const marker = (id: string) => ({
info: { id, role: 'user' },
parts: [{ type: 'compaction', auto: false }]
});
const summary = (id: string, parentID: string, overrides: Record<string, unknown> = {}, text: string | null = '## Objective\n- Did the thing') => ({
info: { id, role: 'assistant', parentID, summary: true, finish: 'provider-terminal', ...overrides },
parts: text === null ? [{ type: 'step-start' }, { type: 'step-finish' }] : [{ type: 'text', text }]
});
describe('captureCompactionMarkerSnapshot', () => {
it('records only pre-existing manual marker IDs before POST so a later result cannot reuse them', async () => {
const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => {
expect(url).toBe('http://127.0.0.1:48273/session/ses_abc/message');
return new Response(JSON.stringify([
{ info: { id: 'msg_1', role: 'user' }, parts: [{ id: 'prt_1', type: 'text', text: 'hello' }] },
{ info: { id: 'msg_2', role: 'assistant' }, parts: [{ id: 'prt_2', type: 'text', text: 'hi there' }] },
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
{
info: { id: 'msg_4', role: 'assistant', parentID: 'msg_3', summary: true },
parts: [
{ id: 'prt_4a', type: 'step-start' },
{ id: 'prt_4b', type: 'reasoning', text: 'thinking about the summary' },
{ id: 'prt_4c', type: 'text', text: '## Objective\n- Did the thing' },
{ id: 'prt_4d', type: 'step-finish' }
]
}
]), { status: 200 });
expect(init).toMatchObject({ method: 'GET', signal: noSignal });
return new Response(JSON.stringify([marker('old-marker')]), { status: 200 });
});
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
expect(result).toEqual({ found: true, text: '## Objective\n- Did the thing' });
await expect(captureCompactionMarkerSnapshot({
baseUrl: 'http://127.0.0.1:48273', sessionId: 'ses_abc', fetchImpl, signal: noSignal
})).resolves.toEqual({ markerIds: ['old-marker'] });
});
it('falls back to positional adjacency when the assistant message has no parentID', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
{ info: { id: 'msg_4', role: 'assistant' }, parts: [{ id: 'prt_4', type: 'text', text: 'summary via positional match' }] }
]), { status: 200 }));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
expect(result).toEqual({ found: true, text: 'summary via positional match' });
});
describe('fetchCompactionResult', () => {
const options = (messages: unknown[]) => ({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
markerIdsBefore: ['old-marker'],
fetchImpl: vi.fn(async () => new Response(JSON.stringify(messages), { status: 200 })),
signal: noSignal
});
it('rejects a parentID/positional match whose role is not assistant, even if it happens to carry a text part', async () => {
// Both the parentID-linked entry AND the positionally-adjacent entry
// have a `type:'text'` part here, but neither is role:'assistant' —
// the safe fallback (found:false) must win rather than surfacing
// whatever unrelated text these entries happen to carry.
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
{ info: { id: 'msg_4', role: 'user', parentID: 'msg_3' }, parts: [{ id: 'prt_4', type: 'text', text: 'not actually a summary' }] }
]), { status: 200 }));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
it('succeeds only for the new marker\'s exactly parent-linked terminal summary with nonblank text', async () => {
const result = await fetchCompactionResult(options([
marker('old-marker'),
summary('old-summary', 'old-marker', {}, 'old summary'),
marker('this-request-marker'),
summary('this-request-summary', 'this-request-marker')
]));
expect(result).toEqual({ found: false });
expect(result).toEqual({ status: 'success', text: '## Objective\n- Did the thing' });
});
it('concatenates multiple text parts in order instead of only taking the first', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
{
info: { id: 'msg_4', role: 'assistant', parentID: 'msg_3' },
parts: [
{ id: 'prt_4a', type: 'text', text: '## Objective\n' },
{ id: 'prt_4b', type: 'step-finish' },
{ id: 'prt_4c', type: 'text', text: '- Did the thing' }
]
}
]), { status: 200 }));
it('does not guess between two post-snapshot manual markers', async () => {
const result = await fetchCompactionResult(options([
marker('old-marker'),
marker('this-request-marker'),
summary('this-request-summary', 'this-request-marker'),
marker('later-manual-marker'),
summary('later-summary', 'later-manual-marker', {}, 'later summary')
]));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
expect(result).toEqual({ found: true, text: '## Objective\n- Did the thing' });
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('returns found:false when no compaction marker exists', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
{ info: { id: 'msg_1', role: 'user' }, parts: [{ id: 'prt_1', type: 'text', text: 'hello' }] },
{ info: { id: 'msg_2', role: 'assistant' }, parts: [{ id: 'prt_2', type: 'text', text: 'hi' }] }
]), { status: 200 }));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
it('does not fall back to adjacent text when the new marker has no exact parent-linked assistant result', async () => {
const result = await fetchCompactionResult(options([
marker('old-marker'),
marker('this-request-marker'),
summary('unrelated-summary', 'another-marker')
]));
expect(result).toEqual({ found: false });
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('returns found:false when the marker is the last message (no following assistant message yet)', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] }
]), { status: 200 }));
const result = await fetchCompactionSummary({
it('keeps a malformed result GET unverified', async () => {
const result = await fetchCompactionResult({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
markerIdsBefore: ['old-marker'],
fetchImpl: vi.fn(async () => new Response('not json', { status: 200 })),
signal: noSignal
});
expect(result).toEqual({ found: false });
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('returns found:false when the following assistant message has no text part', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'compaction', auto: false }] },
{ info: { id: 'msg_4', role: 'assistant', parentID: 'msg_3' }, parts: [{ id: 'prt_4', type: 'step-finish' }] }
]), { status: 200 }));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
expect(result).toEqual({ found: false });
it('classifies the observed HTTP-200, finish-unknown, empty linked summary as failed', async () => {
const result = await fetchCompactionResult(options([
marker('old-marker'),
marker('this-request-marker'),
summary('this-request-summary', 'this-request-marker', {
finish: 'unknown',
tokens: { input: 0, output: 0, reasoning: 0 }
}, null)
]));
expect(result).toEqual({ status: 'failed', reason: 'OpenCode returned an empty compaction summary.' });
});
it('returns found:false on a non-ok response', async () => {
const fetchImpl = vi.fn(async () => new Response(null, { status: 500 }));
it('does not use a finish allowlist when terminal evidence and a valid summary are present', async () => {
const result = await fetchCompactionResult(options([
marker('old-marker'),
marker('this-request-marker'),
summary('this-request-summary', 'this-request-marker', { finish: 'provider-specific-finish' })
]));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
expect(result).toEqual({ found: false });
expect(result).toEqual({ status: 'success', text: '## Objective\n- Did the thing' });
});
it('returns found:false when the response is not valid JSON / not an array', async () => {
const fetchImpl = vi.fn(async () => new Response('not json', { status: 200 }));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
expect(result).toEqual({ found: false });
});
it('returns found:false when the network call throws', async () => {
const fetchImpl = vi.fn(async () => {
throw new Error('ECONNREFUSED');
});
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
it('concatenates every text part from the exact linked summary', async () => {
const result = await fetchCompactionResult(options([
marker('old-marker'),
marker('this-request-marker'),
{
info: {
id: 'this-request-summary',
role: 'assistant',
parentID: 'this-request-marker',
summary: true,
finish: 'provider-terminal'
},
parts: [
{ type: 'text', text: '## Objective\n' },
{ type: 'step-finish' },
{ type: 'text', text: '- Did the thing' }
]
}
]));
expect(result).toEqual({ found: false });
expect(result).toEqual({ status: 'success', text: '## Objective\n- Did the thing' });
});
it('picks the LAST compaction marker when there are multiple (a session may be compacted more than once)', async () => {
const fetchImpl = vi.fn(async () => new Response(JSON.stringify([
{ info: { id: 'msg_1', role: 'user' }, parts: [{ id: 'prt_1', type: 'compaction', auto: false }] },
{ info: { id: 'msg_2', role: 'assistant', parentID: 'msg_1' }, parts: [{ id: 'prt_2', type: 'text', text: 'first summary' }] },
{ info: { id: 'msg_3', role: 'user' }, parts: [{ id: 'prt_3', type: 'text', text: 'more chat' }] },
{ info: { id: 'msg_4', role: 'user' }, parts: [{ id: 'prt_4', type: 'compaction', auto: false }] },
{ info: { id: 'msg_5', role: 'assistant', parentID: 'msg_4' }, parts: [{ id: 'prt_5', type: 'text', text: 'second summary' }] }
]), { status: 200 }));
it('keeps an associated but non-terminal result unverified instead of treating token-less text as a failure', async () => {
const result = await fetchCompactionResult(options([
marker('old-marker'),
marker('this-request-marker'),
summary('this-request-summary', 'this-request-marker', { finish: undefined }, '')
]));
const result = await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: noSignal
});
expect(result).toEqual({ found: true, text: 'second summary' });
expect(result).toEqual({ status: 'unverified', reason: 'Compaction result could not be verified.' });
});
it('forwards an AbortSignal to fetch when provided, so a caller can interrupt an in-flight request', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
expect(init?.signal).toBe(controller.signal);
return new Response(JSON.stringify([]), { status: 200 });
});
await fetchCompactionSummary({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
fetchImpl,
signal: controller.signal
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
it('uses OpenCode APIError.data.message as a normalized, truncated safe failure reason without serializing metadata', async () => {
const providerMessage = ` provider\n unavailable ${'x'.repeat(220)} `;
const result = await fetchCompactionResult(options([
marker('old-marker'),
marker('this-request-marker'),
summary('this-request-summary', 'this-request-marker', {
error: {
name: 'APIError',
data: {
message: providerMessage,
apiKey: 'super-secret-api-key',
requestHeaders: { authorization: 'Bearer super-secret-token' }
}
}
}, null)
]));
expect(result).toEqual({ status: 'failed', reason: `provider unavailable ${'x'.repeat(179)}` });
expect((result as { reason: string }).reason).not.toContain('super-secret');
});
it('resolves with found:false (not a hang or uncaught rejection) when the signal aborts mid-request', async () => {
// Reproduces the exact gap a PR-review round found: the POST
// (triggerOpencodeCompact) had a signal wired through in an earlier
// round, but this GET — which runs right after it inside
// runCompactOperation() — did not, so Stop/switch-to-local could
// still block on this call even after that fix.
it('returns unverified when the semantic-result GET is aborted', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn((_url: string, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => {
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
expect(init?.signal).toBe(controller.signal);
init?.signal?.addEventListener('abort', () => reject(new DOMException('The operation was aborted.', 'AbortError')));
}));
const resultPromise = fetchCompactionSummary({
const resultPromise = fetchCompactionResult({
baseUrl: 'http://127.0.0.1:48273',
sessionId: 'ses_abc',
markerIdsBefore: ['old-marker'],
fetchImpl,
signal: controller.signal
});
controller.abort();
const result = await resultPromise;
expect(result).toEqual({ found: false });
await expect(resultPromise).resolves.toEqual({
status: 'unverified', reason: 'Compaction result could not be verified.'
});
});
});
+129 -67
View File
@@ -2,9 +2,14 @@ export type OpencodeCompactResult =
| { ok: true; summaryText?: string }
| { ok: false; error: string };
export type CompactionSummaryResult =
| { found: true; text: string }
| { found: false };
export type CompactionResult =
| { status: 'success'; text: string }
| { status: 'failed'; reason: string }
| { status: 'unverified'; reason: string };
export type CompactionMarkerSnapshot = {
markerIds: string[];
};
/** Minimal fetch-shaped function signature, kept narrower than `typeof fetch` so tests can pass a plain `vi.fn()` without matching runtime-specific extras (e.g. Bun's `fetch.preconnect`). */
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
@@ -31,7 +36,7 @@ type BunFetchInit = RequestInit & { timeout?: false };
* This was previously opt-in (`signal?: AbortSignal`) on each function
* individually, which is exactly how a real regression happened: a second
* PR-review round later found that `triggerOpencodeCompact` (the POST) had
* been wired up but `fetchCompactionSummary` (the GET that runs right
* been wired up but the result-verification GET (which runs right
* after it) had not, because nothing forced it. Making `signal` a required
* field of a shared base type means the compiler catches a future third
* HTTP step *implemented as a function in this file* without one — it can't
@@ -128,9 +133,16 @@ export async function triggerOpencodeCompact(opts: OpencodeCompactCallOpts & {
}
}
type OpencodeMessagePart = { type?: unknown; text?: unknown };
type OpencodeMessagePart = { type?: unknown; text?: unknown; auto?: unknown };
type OpencodeMessageEntry = {
info?: { id?: unknown; role?: unknown; parentID?: unknown; summary?: unknown };
info?: {
id?: unknown;
role?: unknown;
parentID?: unknown;
summary?: unknown;
finish?: unknown;
error?: unknown;
};
parts?: unknown;
};
@@ -138,16 +150,7 @@ function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
/**
* Only an assistant message is a plausible summary carrier — without this
* check, an unrelated adjacent/linked entry that happens to carry a `text`
* part (e.g. another user message) could silently surface as the "summary",
* bypassing the safe "not found -> skip" fallback this function exists to
* provide. `info.summary === true` (observed on the real compaction
* response) is a stronger corroborating signal when present, but role is the
* one check we always enforce.
*/
function isAssistantSummaryCandidate(entry: OpencodeMessageEntry | undefined): boolean {
function isAssistant(entry: OpencodeMessageEntry | undefined): boolean {
return entry?.info?.role === 'assistant';
}
@@ -160,62 +163,121 @@ function extractTextPart(entry: OpencodeMessageEntry | undefined): string | null
return texts.length > 0 ? texts.join('') : null;
}
/**
* After a successful `triggerOpencodeCompact`, OpenCode's session history
* contains a `{"type":"compaction"}` marker message (role `user`, no text)
* followed by an assistant message whose `text` part holds the actual
* summary OpenCode generated (verified 2026-07-30 via isolated E2E: parts
* were `['step-start','reasoning','text','step-finish']`). This fetches the
* message list and extracts that text so HAPI can show it as a "Reasoning"
* block instead of leaving the summary invisible.
*
* Looks for the assistant message via its `parentID` pointing at the marker
* first (robust to the API returning messages in an order other than
* creation order), falling back to simple positional adjacency (the very
* next array entry) if no `parentID` link is present. If a session has been
* compacted more than once, only the most recent marker is considered.
*
* Never throws — any failure (network error, unexpected response shape, no
* marker found, no text part found, or `signal` — see `OpencodeCompactCallOpts`
* — firing mid-request) resolves to `{ found: false }` so the caller can
* silently skip showing the summary rather than surfacing an error for what
* is a purely cosmetic enhancement.
*/
export async function fetchCompactionSummary(opts: OpencodeCompactCallOpts & {
fetchImpl?: FetchLike;
}): Promise<CompactionSummaryResult> {
function isManualCompactionMarker(entry: OpencodeMessageEntry): boolean {
return Array.isArray(entry.parts)
&& entry.parts.some((part) => isObjectRecord(part) && part.type === 'compaction' && part.auto === false);
}
function getManualCompactionMarkerIds(entries: OpencodeMessageEntry[]): string[] | null {
const markerIds: string[] = [];
for (const entry of entries) {
if (!isManualCompactionMarker(entry)) continue;
if (typeof entry.info?.id !== 'string') return null;
markerIds.push(entry.info.id);
}
return markerIds;
}
function isTerminal(entry: OpencodeMessageEntry): boolean {
// OpenCode/provider finish strings are not an enum HAPI owns. Any
// nonblank string is terminal evidence; an allowlist would reject valid
// provider-specific values such as the observed `unknown` finish.
return typeof entry.info?.finish === 'string' && entry.info.finish.trim().length > 0;
}
function safeErrorReason(value: unknown): string | null {
if (value === undefined || value === null) return null;
// OpenCode persists provider failures as `{ name, data: { message, ... } }`.
// Read only that scalar message: serializing error/data would risk exposing
// request headers, provider metadata, or other sensitive fields.
const message = typeof value === 'string'
? value
: isObjectRecord(value) && isObjectRecord(value.data) && typeof value.data.message === 'string'
? value.data.message
: null;
if (!message) return 'OpenCode reported a compaction error.';
const normalized = message.trim().replace(/\s+/g, ' ');
return normalized ? normalized.slice(0, 200) : 'OpenCode reported a compaction error.';
}
async function fetchSessionMessages(opts: OpencodeCompactCallOpts & { fetchImpl?: FetchLike }): Promise<OpencodeMessageEntry[] | null> {
const fetchFn: FetchLike = opts.fetchImpl ?? fetch;
const url = `${opts.baseUrl}/session/${encodeURIComponent(opts.sessionId)}/message`;
try {
const response = await fetchFn(url, { method: 'GET', signal: opts.signal });
if (!response.ok) return { found: false };
if (!response.ok) return null;
const data: unknown = await response.json().catch(() => null);
if (!Array.isArray(data)) return { found: false };
const entries = data as OpencodeMessageEntry[];
let markerIndex = -1;
for (let i = entries.length - 1; i >= 0; i--) {
const parts = entries[i]?.parts;
if (Array.isArray(parts) && parts.some((part) => isObjectRecord(part) && part.type === 'compaction')) {
markerIndex = i;
break;
}
}
if (markerIndex === -1) return { found: false };
const markerId = entries[markerIndex]?.info?.id;
const byParentId = typeof markerId === 'string'
? entries.find((entry) => entry.info?.parentID === markerId && isAssistantSummaryCandidate(entry))
: undefined;
const positionalCandidate = entries[markerIndex + 1];
const byPosition = isAssistantSummaryCandidate(positionalCandidate) ? positionalCandidate : undefined;
const text = extractTextPart(byParentId) ?? extractTextPart(byPosition);
return text !== null ? { found: true, text } : { found: false };
return Array.isArray(data) ? data as OpencodeMessageEntry[] : null;
} catch {
return { found: false };
return null;
}
}
/**
* Captures manual compaction markers before the summarize POST. The
* post-request verifier uses this boundary instead of selecting the newest
* marker, which would misattribute an older or later manual compaction.
*/
export async function captureCompactionMarkerSnapshot(opts: OpencodeCompactCallOpts & {
fetchImpl?: FetchLike;
}): Promise<CompactionMarkerSnapshot | null> {
const entries = await fetchSessionMessages(opts);
if (!entries) return null;
const markerIds = getManualCompactionMarkerIds(entries);
return markerIds ? { markerIds } : null;
}
/**
* Resolves only the persisted assistant result associated with this exact
* summarize request. HTTP 200 merely proves the endpoint returned; it is not
* success evidence. Unknown shapes, absent association, and non-terminal
* messages remain unverified rather than becoming a false completion.
*/
export async function fetchCompactionResult(opts: OpencodeCompactCallOpts & {
markerIdsBefore: readonly string[] | null;
fetchImpl?: FetchLike;
}): Promise<CompactionResult> {
if (!opts.markerIdsBefore) {
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
}
const entries = await fetchSessionMessages(opts);
if (!entries) {
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
}
const markerIds = getManualCompactionMarkerIds(entries);
if (!markerIds) {
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
}
const before = new Set(opts.markerIdsBefore);
const newMarkerIds = markerIds.filter((markerId) => !before.has(markerId));
if (newMarkerIds.length !== 1) {
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
}
const linkedResults = entries.filter((entry) =>
isAssistant(entry) && entry.info?.parentID === newMarkerIds[0]
);
if (linkedResults.length !== 1) {
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
}
const result = linkedResults[0]!;
const error = safeErrorReason(result.info?.error);
if (error) {
return { status: 'failed', reason: error };
}
const text = extractTextPart(result);
if (isTerminal(result) && (!text || text.trim().length === 0)) {
return { status: 'failed', reason: 'OpenCode returned an empty compaction summary.' };
}
if (result.info?.summary === true && isTerminal(result) && text && text.trim().length > 0) {
return { status: 'success', text };
}
return { status: 'unverified', reason: 'Compaction result could not be verified.' };
}