Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/main/__tests__/session-trace-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ describe('session trace refresh policy', () => {
'token_usage',
'provider_retry',
// A mid-turn compaction persists a boundary note the trace must show.
'context_compaction_started',
'context_compaction_applied',
'context_compaction_failed',
'complete',
'error',
'abort',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ const TRACE_RELEVANT_EVENT_TYPES: ReadonlySet<SessionEvent['type']> = new Set([
'token_usage',
'provider_retry',
// Its whole ledger effect is a persisted boundary note; easy to miss that it counts.
'context_compaction_started',
'context_compaction_applied',
'context_compaction_failed',
'error',
'complete',
'abort',
Expand Down
35 changes: 35 additions & 0 deletions packages/cli/src/__tests__/pi-transcript.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,41 @@ describe('Maka Pi TUI transcript', () => {
assert.doesNotMatch(rendered, /context_provider_dropping|truncated or rewritten/);
});

test('shows an interrupted compaction separately from a failed summary', () => {
const state = createMakaPiTranscriptState();
replaceTranscriptWithStoredMessages(state, [
{
type: 'system_note',
id: 'interrupted',
turnId: 't1',
ts: 1,
kind: 'context_compaction_failed_open',
data: { failOpenReason: 'interrupted' },
},
{
type: 'system_note',
id: 'failed',
turnId: 't1',
ts: 2,
kind: 'context_compaction_failed_open',
data: { failOpenReason: 'write_failed' },
},
]);

assert.deepEqual(
state.entries
.filter((entry) => entry.kind === 'notice')
.map((entry) => ({ level: entry.level, text: entry.text })),
[
{ level: 'info', text: 'Context compaction was interrupted.' },
{
level: 'info',
text: 'Context summary failed; the session continued without a new summary.',
},
],
);
});

test('renders manual compaction from the typed terminal outcome', async () => {
for (const [outcome, expected] of [
[{ kind: 'compacted' as const, checkpointId: 'checkpoint-1' }, 'Context compacted.'],
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/pi-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ import {
} from '@maka/core/session';
import type { ContextBudgetDiagnostic } from '@maka/core/usage-stats/types';
import { providerRetryDisplaySeconds } from '@maka/core/provider-retry-countdown';
import {
compactionStateForFailure,
readCompactionFailureReason,
} from '@maka/core/compaction-status';
import type { ThinkingLevel } from '@maka/core/model-thinking';
import {
defineUiMessageCatalog,
Expand Down Expand Up @@ -1361,7 +1365,9 @@ function systemNoteText(message: SystemNoteMessage): string | undefined {
case 'context_compacted':
return 'Context compacted to keep this task within the model window.';
case 'context_compaction_failed_open':
return 'Context summary failed; the session continued without a new summary.';
return compactionStateForFailure(readCompactionFailureReason(message.data)) === 'interrupted'
? 'Context compaction was interrupted.'
: 'Context summary failed; the session continued without a new summary.';
case 'context_overflow_after_compaction':
return 'History was compacted and the provider still called this request too large. What remains also carries the system prompt, the tool schemas, the summary and the recent tail; shortening this message is the part you control.';
case 'context_reported_window_exceeded': {
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"./durable-tool-result-projection": "./dist/durable-tool-result-projection.js",
"./model-projection-transition": "./dist/model-projection-transition.js",
"./canonical-runtime-event": "./dist/canonical-runtime-event.js",
"./compaction-status": "./dist/compaction-status.js",
"./canonical-json": "./dist/canonical-json.js",
"./runtime-boundary": "./dist/runtime-boundary.js",
"./runtime-handoff": "./dist/runtime-handoff.js",
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/backend-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ export type BackendSessionEvent = Exclude<
| 'message_admission'
| 'client_capability_request'
| 'client_capability_decision_ack'
| 'context_compaction_started'
| 'permission_request'
| 'permission_answer_ack'
| 'permission_closure_ack'
Expand Down
109 changes: 109 additions & 0 deletions packages/core/src/compaction-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import type { StoredMessage, TurnStatus } from './session.js';

export type CompactionTimelineState = 'running' | 'compacted' | 'failed' | 'interrupted';

export interface CompactionTimelineMarker {
compactionId: string;
state: CompactionTimelineState;
ts: number;
/** Present only when `state === 'failed'`; cancellation uses `interrupted`. */
reason?: string;
}

/** A cancelled attempt is a distinct outcome from a compaction that failed. */
export function compactionStateForFailure(reason: string | undefined): 'failed' | 'interrupted' {
// `aborted` is how the explicit compaction path names a stopped run; both
// spellings mean the reader cancelled, not that folding failed.
return reason === 'interrupted' || reason === 'aborted' ? 'interrupted' : 'failed';
}

/** A failed Turn ends a running attempt as failed; any other terminal Turn cuts it off. */
export function compactionStateForTurnStatus(
status: Exclude<TurnStatus, 'running'>,
): 'failed' | 'interrupted' {
return status === 'failed' ? 'failed' : 'interrupted';
}

function recordValue(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}

export function readCompactionFailureReason(value: unknown): string | undefined {
const data = recordValue(value);
return typeof data?.failOpenReason === 'string'
? data.failOpenReason
: typeof data?.reason === 'string'
? data.reason
: undefined;
}

/**
* Reads one durable marker of an in-Turn compaction lifecycle.
*
* A timeline row is an attempt, so it requires an explicit `compactionId`.
* Rows written before the lifecycle existed have no attempt identity and stay
* in the transcript as their original notes (`context_compacted` or a visible
* failure note); they never become timeline attempts. Do not fall back to
* `turnId` or turn order: one Turn may contain several compaction attempts.
*/
export function readCompactionTimelineMarker(
message: StoredMessage,
): CompactionTimelineMarker | undefined {
if (message.type !== 'system_note') return undefined;
if (
message.kind !== 'context_compaction_started' &&
message.kind !== 'context_compaction_applied' &&
message.kind !== 'context_compaction_failed_open'
)
return undefined;
const data = recordValue(message.data);
const scope = data?.scope === 'step' ? 'step' : data?.scope === 'turn' ? 'turn' : undefined;
const compactionId =
typeof data?.compactionId === 'string' && data.compactionId.length > 0
? data.compactionId
: undefined;
// A marker without an attempt identity cannot be paired with its terminal
// event and must not become a timeline row.
if (compactionId === undefined) return undefined;
const reason = readCompactionFailureReason(message.data);
if (message.kind === 'context_compaction_started') {
if (scope !== 'step') return undefined;
return { compactionId, state: 'running', ts: message.ts };
}
if (message.kind === 'context_compaction_applied') {
if (scope !== 'step') return undefined;
return { compactionId, state: 'compacted', ts: message.ts };
}
// Legacy failed_open rows were written at settlement; do not move them into
// the timeline with a misleading position. Only the new step-scoped marker
// carries an attempt identity and an apply-time position.
if (scope !== 'step') return undefined;
const state = compactionStateForFailure(reason);
return {
compactionId,
state,
ts: message.ts,
...(state === 'failed' && reason !== undefined ? { reason } : {}),
};
}
40 changes: 35 additions & 5 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,7 +687,8 @@ export type SessionEvent =
| CompleteEvent
| AbortEvent
| ContextCompactionAppliedEvent
| ContextCompactionStartedEvent;
| ContextCompactionStartedEvent
| ContextCompactionFailedEvent;

export interface TextDeltaEvent extends BaseEvent {
type: 'text_delta';
Expand Down Expand Up @@ -1437,13 +1438,21 @@ export interface AbortEvent extends BaseEvent {
}

/**
* A host-owned explicit context-compaction Turn has started. Synthesized by the
* Runtime Host session projector (not the kernel) purely so a client can render
* a "compacting" transcript row while the Turn is in flight; it carries no
* durable state and is excluded from `BackendSessionEvent` like `queue_update`.
* A context-compaction execution started. The Host synthesizes the `turn`
* scope for an explicit compaction Turn; the Runtime emits the `step` scope
* before an automatic in-Turn fold so the client can render the attempt in the
* execution timeline. `compactionId` pairs the start with its terminal event.
*/
export interface ContextCompactionStartedEvent extends BaseEvent {
type: 'context_compaction_started';
/**
* Required for a `step` attempt. A turn-scoped event (including a legacy /
* manually synthesized one without this field) describes the whole Turn and
* never becomes an attempt timeline row.
*/
compactionId?: string;
/** `turn` = the whole Turn is an explicit compaction; `step` = in-Turn fold. */
scope?: 'turn' | 'step';
}

/**
Expand All @@ -1453,6 +1462,27 @@ export interface ContextCompactionStartedEvent extends BaseEvent {
*/
export interface ContextCompactionAppliedEvent extends BaseEvent {
type: 'context_compaction_applied';
/**
* Required for a new `step` attempt. Legacy applied records have no attempt
* identity and no start to pair with; they stay hidden or render as the
* existing settlement note, never as a timeline attempt. Never pair by
* `turnId` or turn order: one Turn may contain several attempts.
*/
compactionId?: string;
/** `step` for an automatic in-Turn fold; `turn` for explicit compaction. */
scope?: 'turn' | 'step';
}

/**
* An automatic in-Turn compaction attempt failed open. The Turn continues with
* the un-compacted projection; this event only settles the timeline row.
*/
export interface ContextCompactionFailedEvent extends BaseEvent {
type: 'context_compaction_failed';
compactionId: string;
scope: 'step';
/** `interrupted` means the attempt was cancelled, not that folding failed. */
reason: string;
}

// ============================================================================
Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,10 +856,14 @@ export function userFacingText(message: Pick<UserMessage, 'text' | 'displayText'
* exactly the notes the runtime writes.
*/
export function isUserVisibleSessionSystemNote(kind: string): boolean {
// `context_compaction_applied` is recorded the moment a compaction is applied —
// inside a still-running turn — so usage readers can order the boundary
// against request completions. `context_compacted` stays the display row.
return isRuntimeSystemNoteKind(kind) && kind !== 'context_compaction_applied';
// Applied/started are compaction lifecycle facts recorded inside a still-
// running turn. They drive the execution timeline; `context_compacted` stays
// the transcript note for legacy/manual display.
return (
isRuntimeSystemNoteKind(kind) &&
kind !== 'context_compaction_applied' &&
kind !== 'context_compaction_started'
);
}

/**
Expand Down Expand Up @@ -1310,6 +1314,7 @@ export interface TurnRecord {
*/
export const RUNTIME_SYSTEM_NOTE_KINDS = [
'context_compacted',
'context_compaction_started',
'context_compaction_applied',
'context_compaction_failed_open',
'context_window_suggestion',
Expand Down Expand Up @@ -1356,10 +1361,12 @@ export interface SystemNoteMessage {
kind: SystemNoteKind;
/**
* Shape depends on `kind`. `context_compaction_failed_open` carries
* `{ failOpenReason?: string }` — the reason the fold was refused (e.g.
* `coverage_miss`, `source_hash_mismatch`); when a turn is stopped before
* settlement, this note is the only durable record of the reason, because
* the `token_usage` diagnostic is never written (#4850).
* `{ failOpenReason?: string }` plus, for an in-Turn attempt,
* `{ compactionId, scope: 'step' }`. The reason records why the fold was
* refused (e.g. `coverage_miss`, `source_hash_mismatch`) or `interrupted`
* when the user cancelled it; when a turn is stopped before settlement, this
* note is the only durable record of the reason, because the `token_usage`
* diagnostic is never written (#4850).
*/
data?: unknown;
}
Expand Down
44 changes: 44 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,8 @@ describe('Runtime Host bootstrap protocol', () => {
id: 'compaction-1',
turnId: 'turn-1',
ts: 123456789,
compactionId: 'compaction-1',
scope: 'step' as const,
},
};
const encoded = encodeProtocolMessage(frame);
Expand All @@ -628,6 +630,48 @@ describe('Runtime Host bootstrap protocol', () => {
}
});

test('roundtrips in-turn compaction lifecycle frames with closed event shapes', () => {
for (const event of [
{
type: 'context_compaction_started' as const,
id: 'compaction-started-1',
turnId: 'turn-1',
ts: 123456789,
compactionId: 'compact-1',
scope: 'step' as const,
},
{
type: 'context_compaction_failed' as const,
id: 'compaction-failed-1',
turnId: 'turn-1',
ts: 123456790,
compactionId: 'compact-1',
scope: 'step' as const,
reason: 'write_failed',
},
]) {
const frame = {
kind: 'subscription.session_event' as const,
hostEpoch: 'epoch-1',
subscriptionId: 'subscription-1',
sequence: 2,
sessionId: 'session-1',
runId: 'run-1',
event,
};
const encoded = encodeProtocolMessage(frame);
assert.deepEqual(decodeHostFrame(JSON.parse(encoded.toString('utf8'))), frame);
assert.throws(
() => decodeHostFrame({ ...frame, event: { ...event, scope: 'turn' } }),
isInvalidFrame,
);
assert.throws(
() => decodeHostFrame({ ...frame, event: { ...event, compactionId: '' } }),
isInvalidFrame,
);
}
});

test('preserves opaque nested call and step identities in subscription tool events', () => {
const parentId = 'p'.repeat(128);
const frame = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,8 @@ test('forwards compaction boundaries while the root turn is still running', asyn
id: `compaction-${index}`,
turnId: 'compaction-turn',
ts: 123456789 + index,
compactionId: `compaction-${index}`,
scope: 'step' as const,
}));
const fixture = await createFailureFixture({
registerBackend: (backends) =>
Expand Down
Loading