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
17 changes: 17 additions & 0 deletions packages/core/src/__tests__/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
aggregateMessageContents,
decodeToolStepProgress,
encodeToolStepProgress,
isContextCompactionOutcome,
isLiveContextCompactionOutcome,
} from '../events.js';

test('aggregates inline references against the combined display text', () => {
Expand Down Expand Up @@ -97,3 +99,18 @@ test('rejects invalid tool step progress at both codec boundaries', () => {
}
expect(decodeToolStepProgress({ kind: 'stdout', text: 'steps:1/2' })).toBe(undefined);
});

test('accepts copied compaction history without a transferable checkpoint', () => {
expect(isContextCompactionOutcome({ kind: 'compacted', checkpointId: null })).toBe(true);
expect(isLiveContextCompactionOutcome({ kind: 'compacted', checkpointId: null })).toBe(false);
expect(isContextCompactionOutcome({ kind: 'compacted', checkpointId: 'checkpoint-1' })).toBe(
true,
);
expect(isLiveContextCompactionOutcome({ kind: 'compacted', checkpointId: 'checkpoint-1' })).toBe(
true,
);
expect(isContextCompactionOutcome({ kind: 'compacted' })).toBe(false);
expect(isContextCompactionOutcome({ kind: 'compacted', checkpointId: null, extra: true })).toBe(
false,
);
});
4 changes: 2 additions & 2 deletions packages/core/src/backend-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

import type {
AttachmentRef,
ContextCompactionOutcome,
LiveContextCompactionOutcome,
MessageContent,
QuoteRef,
SessionEvent,
Expand Down Expand Up @@ -170,7 +170,7 @@ export interface BackendCompactHistoryInput {
}

export interface BackendCompactHistoryResult {
outcome: ContextCompactionOutcome;
outcome: LiveContextCompactionOutcome;
contextBudget?: ContextBudgetDiagnostic;
}

Expand Down
38 changes: 37 additions & 1 deletion packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1166,10 +1166,46 @@ export interface CompleteEvent extends BaseEvent {
}

export type ContextCompactionOutcome =
| { kind: 'compacted'; checkpointId: string }
| {
kind: 'compacted';
/**
* `null` only when copied history intentionally omits a provider-bound
* checkpoint that cannot be transferred safely to the target Session.
*/
checkpointId: string | null;
}
| { kind: 'unchanged'; reason: string }
| { kind: 'failed'; reason: string };

/** A live compaction result must always name the checkpoint it just created. */
export type LiveContextCompactionOutcome =
| { kind: 'compacted'; checkpointId: string }
| Extract<ContextCompactionOutcome, { kind: 'unchanged' | 'failed' }>;

export function isContextCompactionOutcome(value: unknown): value is ContextCompactionOutcome {
if (!isRecord(value)) return false;
if (value.kind === 'compacted') {
return (
Object.keys(value).length === 2 &&
(typeof value.checkpointId === 'string' || value.checkpointId === null)
);
}
return (
(value.kind === 'unchanged' || value.kind === 'failed') &&
Object.keys(value).length === 2 &&
typeof value.reason === 'string'
);
}

export function isLiveContextCompactionOutcome(
value: unknown,
): value is LiveContextCompactionOutcome {
return (
isContextCompactionOutcome(value) &&
(value.kind !== 'compacted' || typeof value.checkpointId === 'string')
);
}

export type ContextBudgetExhaustedDetail =
| 'no_safe_completed_span'
| 'summarizer_failed'
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@

import {
decodeMessageContent,
isContextCompactionOutcome,
TOOL_ACTIVITY_KINDS,
type ContextCompactionOutcome,
type MessageContent,
type ToolActivityKind,
type ToolResultContent,
Expand Down Expand Up @@ -881,6 +883,10 @@ export interface TurnStateMessage {
/** Diagnostic source for user/renderer-triggered aborts, e.g. renderer.stop_button. */
abortSource?: string;
errorClass?: string;
/** Identifies a host-owned Turn that runs explicit context compaction. */
rootExecutionKind?: 'context_compact';
/** Durable terminal outcome for a context-compaction Turn. */
contextCompactionOutcome?: ContextCompactionOutcome;
partialOutputRetained: boolean;
}

Expand All @@ -906,6 +912,8 @@ export interface TurnRecord {
abortedAt?: number;
abortSource?: string;
errorClass?: string;
rootExecutionKind?: 'context_compact';
contextCompactionOutcome?: ContextCompactionOutcome;
partialOutputRetained: boolean;
}

Expand Down Expand Up @@ -1004,6 +1012,8 @@ const TURN_STATE_MESSAGE_SHAPE = defineObjectShape<TurnStateMessage>()(
'abortedAt',
'abortSource',
'errorClass',
'rootExecutionKind',
'contextCompactionOutcome',
],
);
const SYSTEM_NOTE_MESSAGE_SHAPE = defineObjectShape<SystemNoteMessage>()(
Expand Down Expand Up @@ -1149,7 +1159,11 @@ function decodeMessage(
isOptionalString(message.parentSessionId) &&
(message.abortedAt === undefined || isFiniteNumber(message.abortedAt)) &&
isOptionalString(message.abortSource) &&
isOptionalString(message.errorClass)
isOptionalString(message.errorClass) &&
(message.rootExecutionKind === undefined ||
message.rootExecutionKind === 'context_compact') &&
(message.contextCompactionOutcome === undefined ||
isContextCompactionOutcome(message.contextCompactionOutcome))
)
return message as unknown as TurnStateMessage;
break;
Expand Down Expand Up @@ -1276,6 +1290,12 @@ export function deriveTurnRecords(messages: readonly StoredMessage[]): TurnRecor
...(latestState.abortedAt !== undefined ? { abortedAt: latestState.abortedAt } : {}),
...(latestState.abortSource ? { abortSource: latestState.abortSource } : {}),
...(latestState.errorClass ? { errorClass: latestState.errorClass } : {}),
...(latestState.rootExecutionKind
? { rootExecutionKind: latestState.rootExecutionKind }
: {}),
...(latestState.contextCompactionOutcome
? { contextCompactionOutcome: latestState.contextCompactionOutcome }
: {}),
partialOutputRetained: latestState.partialOutputRetained || partialOutputRetained,
};
}
Expand Down
57 changes: 57 additions & 0 deletions packages/runtime-host/src/__tests__/context-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,42 @@ test('context operations preserve bounded exact wire values', () => {
},
},
);
assert.deepEqual(
decodeHostFrame({
requestId: 'request-copied-compact-finished',
operation: 'context.compact',
ok: true,
result: {
kind: 'finished',
turn: {
sessionId: 'session-1',
turnId: 'compact-2',
runId: 'run-compact-2',
status: 'completed',
terminalEventId: 'event-compact-2',
contextCompactionOutcome: { kind: 'compacted', checkpointId: null },
},
outcome: { kind: 'compacted', checkpointId: null },
},
}),
{
requestId: 'request-copied-compact-finished',
operation: 'context.compact',
ok: true,
result: {
kind: 'finished',
turn: {
sessionId: 'session-1',
turnId: 'compact-2',
runId: 'run-compact-2',
status: 'completed',
terminalEventId: 'event-compact-2',
contextCompactionOutcome: { kind: 'compacted', checkpointId: null },
},
outcome: { kind: 'compacted', checkpointId: null },
},
},
);
});

test('context operations reject open shapes and invalid diagnostics', () => {
Expand Down Expand Up @@ -193,6 +229,27 @@ test('context operations reject open shapes and invalid diagnostics', () => {
}),
isProtocolError,
);
assert.throws(
() =>
decodeHostFrame({
requestId: 'request-compact-missing-checkpoint-field',
operation: 'context.compact',
ok: true,
result: {
kind: 'finished',
turn: {
sessionId: 'session-1',
turnId: 'compact-1',
runId: 'run-compact-1',
status: 'completed',
terminalEventId: 'event-compact-1',
contextCompactionOutcome: { kind: 'compacted' },
},
outcome: { kind: 'compacted' },
},
}),
isProtocolError,
);
});

function isProtocolError(error: unknown): boolean {
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ describe('Runtime Host bootstrap protocol', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 43);
});

test('publishes a new compatibility epoch for context-compaction transcript state', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 48);
});

test('selects the highest mutually supported protocol and rejects a gap', () => {
assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0);
assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3);
Expand Down
37 changes: 37 additions & 0 deletions packages/runtime-host/src/__tests__/session-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,43 @@ test('applies authoritative replacement once and does not complete it again at T
);
});

test('preserves the typed context compaction outcome on projected completion', () => {
const projector = new RuntimeHostSessionProjector(
snapshot(),
createRuntimeHostSessionProjectionSeed([], snapshot()),
() => 10,
);

const events = projector.accept({
kind: 'subscription.session_projection',
hostEpoch: 'host-1',
subscriptionId: 'subscription-1',
sequence: 1,
snapshot: snapshot({
projectionRevision: 2,
rootTurn: {
sessionId: 'session-1',
turnId: 'turn-1',
runId: 'run-1',
status: 'completed',
terminalEventId: 'terminal-1',
contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' },
},
}),
}).events;

assert.deepEqual(events, [
{
type: 'complete',
id: 'terminal-1',
turnId: 'turn-1',
ts: 10,
stopReason: 'end_turn',
contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' },
},
]);
});

test('reseeds the latest provider retry when the active Turn still carries one', () => {
const retry = {
phase: 'scheduled' as const,
Expand Down
Loading