diff --git a/packages/core/src/__tests__/events.test.ts b/packages/core/src/__tests__/events.test.ts index ab55410668..c071a147f6 100644 --- a/packages/core/src/__tests__/events.test.ts +++ b/packages/core/src/__tests__/events.test.ts @@ -23,6 +23,8 @@ import { aggregateMessageContents, decodeToolStepProgress, encodeToolStepProgress, + isContextCompactionOutcome, + isLiveContextCompactionOutcome, } from '../events.js'; test('aggregates inline references against the combined display text', () => { @@ -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, + ); +}); diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 8990812e8b..79c84773fb 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -29,7 +29,7 @@ import type { AttachmentRef, - ContextCompactionOutcome, + LiveContextCompactionOutcome, MessageContent, QuoteRef, SessionEvent, @@ -170,7 +170,7 @@ export interface BackendCompactHistoryInput { } export interface BackendCompactHistoryResult { - outcome: ContextCompactionOutcome; + outcome: LiveContextCompactionOutcome; contextBudget?: ContextBudgetDiagnostic; } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 4a58287e49..1344f665cb 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -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; + +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' diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b1dd1e694c..2baba6197c 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -19,7 +19,9 @@ import { decodeMessageContent, + isContextCompactionOutcome, TOOL_ACTIVITY_KINDS, + type ContextCompactionOutcome, type MessageContent, type ToolActivityKind, type ToolResultContent, @@ -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; } @@ -906,6 +912,8 @@ export interface TurnRecord { abortedAt?: number; abortSource?: string; errorClass?: string; + rootExecutionKind?: 'context_compact'; + contextCompactionOutcome?: ContextCompactionOutcome; partialOutputRetained: boolean; } @@ -1004,6 +1012,8 @@ const TURN_STATE_MESSAGE_SHAPE = defineObjectShape()( 'abortedAt', 'abortSource', 'errorClass', + 'rootExecutionKind', + 'contextCompactionOutcome', ], ); const SYSTEM_NOTE_MESSAGE_SHAPE = defineObjectShape()( @@ -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; @@ -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, }; } diff --git a/packages/runtime-host/src/__tests__/context-protocol.test.ts b/packages/runtime-host/src/__tests__/context-protocol.test.ts index 2e555ff508..dd7774cbe0 100644 --- a/packages/runtime-host/src/__tests__/context-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/context-protocol.test.ts @@ -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', () => { @@ -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 { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index c4f2fad83b..c71c75c39a 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -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); diff --git a/packages/runtime-host/src/__tests__/session-projector.test.ts b/packages/runtime-host/src/__tests__/session-projector.test.ts index e2138d3f78..011e3ee73b 100644 --- a/packages/runtime-host/src/__tests__/session-projector.test.ts +++ b/packages/runtime-host/src/__tests__/session-projector.test.ts @@ -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, diff --git a/packages/runtime-host/src/__tests__/session-turns.test.ts b/packages/runtime-host/src/__tests__/session-turns.test.ts index 945e36e780..8e760d922f 100644 --- a/packages/runtime-host/src/__tests__/session-turns.test.ts +++ b/packages/runtime-host/src/__tests__/session-turns.test.ts @@ -110,6 +110,156 @@ test('bounds turn diagnostics before publishing a contribution', () => { ); }); +test('preserves context-compaction state through the Turn query projection', () => { + const contribution = projectSessionTurnContributionForWire({ + turnId: 'turn-compact', + firstSequence: 4, + latestState: { + sequence: 5, + message: { + type: 'turn_state', + id: 'state-compact', + turnId: 'turn-compact', + ts: 10, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + partialOutputRetained: false, + }, + }, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }); + + assert.deepEqual(projectSessionTurnContribution(contribution), { + turnId: 'turn-compact', + firstSequence: 4, + status: 'completed', + statusSource: 'recorded', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + partialOutputRetained: false, + }); + assert.doesNotThrow(() => + decodeSessionTurnsQueryResult({ + sessionId: 'session-1', + throughSequence: 5, + contributions: [contribution], + nextPosition: null, + }), + ); +}); + +test('preserves copied provider-native compaction state without a checkpoint reference', () => { + const contribution = projectSessionTurnContributionForWire({ + turnId: 'turn-compact', + firstSequence: 4, + latestState: { + sequence: 5, + message: { + type: 'turn_state', + id: 'state-compact', + turnId: 'turn-compact', + ts: 10, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'compacted', checkpointId: null }, + partialOutputRetained: false, + }, + }, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }); + + assert.deepEqual(contribution.latestState?.message.contextCompactionOutcome, { + kind: 'compacted', + checkpointId: null, + }); + assert.doesNotThrow(() => + decodeSessionTurnsQueryResult({ + sessionId: 'session-1', + throughSequence: 5, + contributions: [contribution], + nextPosition: null, + }), + ); +}); + +test('bounds context-compaction reasons before publishing a contribution', () => { + const contribution = projectSessionTurnContributionForWire({ + turnId: 'turn-compact', + firstSequence: 4, + latestState: { + sequence: 5, + message: { + type: 'turn_state', + id: 'state-compact', + turnId: 'turn-compact', + ts: 10, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'failed', reason: `a${'🙂'.repeat(200)}` }, + partialOutputRetained: false, + }, + }, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }); + + assert.deepEqual(contribution.latestState?.message.contextCompactionOutcome, { + kind: 'failed', + reason: `a${'🙂'.repeat(127)}`, + }); + assert.doesNotThrow(() => + decodeSessionTurnsQueryResult({ + sessionId: 'session-1', + throughSequence: 5, + contributions: [contribution], + nextPosition: null, + }), + ); +}); + +test('rejects invalid context-compaction checkpoints before publishing a contribution', () => { + assert.throws(() => + projectSessionTurnContributionForWire({ + turnId: 'turn-compact', + firstSequence: 4, + latestState: { + sequence: 5, + message: { + type: 'turn_state', + id: 'state-compact', + turnId: 'turn-compact', + ts: 10, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'compacted', checkpointId: 'invalid checkpoint' }, + partialOutputRetained: false, + }, + }, + userPromptPreview: null, + hasAssistantMessage: false, + hasAssistantOutput: false, + hasToolResult: false, + hasFailedToolResult: false, + hasAbortNote: false, + }), + ); +}); + test('rejects invalid turn-state references before publishing a contribution', () => { assert.throws(() => projectSessionTurnContributionForWire({ diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 09760af34a..2980a1744c 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -236,6 +236,9 @@ export class RuntimeHostSessionProjector { turnId, ts: terminal.ts, stopReason: 'end_turn', + ...(terminal.contextCompactionOutcome + ? { contextCompactionOutcome: terminal.contextCompactionOutcome } + : {}), }); } else if (terminal.status === 'failed') { const reason = terminal.errorClass ?? 'runtime_error'; @@ -272,6 +275,9 @@ export class RuntimeHostSessionProjector { turnId: turn.turnId, ts, stopReason: 'end_turn', + ...(turn.contextCompactionOutcome + ? { contextCompactionOutcome: turn.contextCompactionOutcome } + : {}), }, ]; } @@ -409,6 +415,9 @@ export class RuntimeHostSessionProjector { turnId: root.turnId, ts: this.#now(), stopReason: 'end_turn', + ...(root.contextCompactionOutcome + ? { contextCompactionOutcome: root.contextCompactionOutcome } + : {}), }); } else if (root.status === 'failed') { events.push({ diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 19d5dd1f5b..ebfaeaf222 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 49 as const; +// 49: Turn-state transcript records carry explicit context-compaction identity +// and outcome. Epoch-48 peers reject these added fields in the strict shape. // 48: Session branch creation accepts an explicit Side Conversation intent. // Older peers reject the strict input shape or cannot apply its snapshot semantics. // 47: Project registration can carry an explicit location preference. Epoch-46 diff --git a/packages/runtime-host/src/protocol/session-turns.ts b/packages/runtime-host/src/protocol/session-turns.ts index c615b7152a..a3b42f4978 100644 --- a/packages/runtime-host/src/protocol/session-turns.ts +++ b/packages/runtime-host/src/protocol/session-turns.ts @@ -28,6 +28,10 @@ import { } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; import { defineOperation } from './operation-spec.js'; +import { + CONTEXT_COMPACTION_OUTCOME_REASON_MAX_LENGTH, + decodeContextCompactionOutcome, +} from './turn.js'; export const SESSION_TURN_QUERY_MAX_CONTRIBUTIONS = 128; export const SESSION_TURN_QUERY_RESULT_MAX_BYTES = 192 * 1024; @@ -179,10 +183,39 @@ function projectTurnStateMessageForWire(message: TurnStateMessage): TurnStateMes ...(message.errorClass ? { errorClass: truncateUtf8(message.errorClass, SESSION_TURN_DIAGNOSTIC_MAX_BYTES) } : {}), + ...(message.rootExecutionKind ? { rootExecutionKind: message.rootExecutionKind } : {}), + ...(message.contextCompactionOutcome + ? { + contextCompactionOutcome: projectContextCompactionOutcomeForWire( + message.contextCompactionOutcome, + ), + } + : {}), partialOutputRetained: message.partialOutputRetained, }; } +function projectContextCompactionOutcomeForWire( + outcome: NonNullable, +): NonNullable { + if (outcome.kind !== 'unchanged' && outcome.kind !== 'failed') { + return decodeContextCompactionOutcome(outcome); + } + return decodeContextCompactionOutcome({ + ...outcome, + reason: truncateWithoutSplittingSurrogate( + outcome.reason, + CONTEXT_COMPACTION_OUTCOME_REASON_MAX_LENGTH, + ), + }); +} + +function truncateWithoutSplittingSurrogate(value: string, maxLength: number): string { + const truncated = value.slice(0, maxLength); + const lastCodeUnit = truncated.charCodeAt(truncated.length - 1); + return lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff ? truncated.slice(0, -1) : truncated; +} + export function projectSessionTurnContribution(contribution: SessionTurnContribution): TurnRecord { const state = contribution.latestState?.message; const partialOutputRetained = contribution.hasAssistantOutput || contribution.hasToolResult; @@ -205,6 +238,10 @@ export function projectSessionTurnContribution(contribution: SessionTurnContribu ...(state.abortedAt !== undefined ? { abortedAt: state.abortedAt } : {}), ...(state.abortSource ? { abortSource: state.abortSource } : {}), ...(state.errorClass ? { errorClass: state.errorClass } : {}), + ...(state.rootExecutionKind ? { rootExecutionKind: state.rootExecutionKind } : {}), + ...(state.contextCompactionOutcome + ? { contextCompactionOutcome: state.contextCompactionOutcome } + : {}), partialOutputRetained: state.partialOutputRetained || partialOutputRetained, }; } diff --git a/packages/runtime-host/src/protocol/turn.ts b/packages/runtime-host/src/protocol/turn.ts index 100cb6c230..b9db8f5907 100644 --- a/packages/runtime-host/src/protocol/turn.ts +++ b/packages/runtime-host/src/protocol/turn.ts @@ -78,6 +78,7 @@ export const TURN_MESSAGE_QUOTE_TEXT_MAX_LENGTH = 32_000; export const TURN_MESSAGE_QUOTE_LABEL_MAX_LENGTH = 200; export const TURN_SKILL_ID_MAX_COUNT = 50; export const TURN_SKILL_ID_MAX_LENGTH = 512; +export const CONTEXT_COMPACTION_OUTCOME_REASON_MAX_LENGTH = 256; const ATTACHMENT_NAME_MAX_BYTES = 512; const ATTACHMENT_MIME_TYPE_MAX_BYTES = 256; const ATTACHMENT_PATH_MAX_BYTES = 4096; @@ -715,11 +716,18 @@ export function decodeContextCompactionOutcome(value: unknown): ContextCompactio const kind = requireString(record.kind, 'kind', 32); if (kind === 'compacted') { assertExactKeys(record, 'compacted context outcome', ['kind', 'checkpointId']); - return { kind, checkpointId: requireEntityId(record.checkpointId, 'checkpointId') }; + return { + kind, + checkpointId: + record.checkpointId === null ? null : requireEntityId(record.checkpointId, 'checkpointId'), + }; } if (kind === 'unchanged' || kind === 'failed') { assertExactKeys(record, `${kind} context outcome`, ['kind', 'reason']); - return { kind, reason: requireString(record.reason, 'reason', 256) }; + return { + kind, + reason: requireString(record.reason, 'reason', CONTEXT_COMPACTION_OUTCOME_REASON_MAX_LENGTH), + }; } throw invalidProtocolFrame('Invalid context compaction outcome kind'); } diff --git a/packages/runtime-host/src/server/canonical-turn-snapshot.ts b/packages/runtime-host/src/server/canonical-turn-snapshot.ts index 24cf1f3e7a..06ff4112c5 100644 --- a/packages/runtime-host/src/server/canonical-turn-snapshot.ts +++ b/packages/runtime-host/src/server/canonical-turn-snapshot.ts @@ -18,7 +18,7 @@ */ import type { AgentRunHeader } from '@maka/core/agent-run'; -import type { ContextCompactionOutcome } from '@maka/core/events'; +import { isContextCompactionOutcome } from '@maka/core/events'; import { truncateUtf8 } from '@maka/core/diagnostic-log'; import { redactSecrets } from '@maka/core/redaction'; import { classifyTerminalRuntimeLedger } from '@maka/runtime/terminal-run-commit'; @@ -56,9 +56,11 @@ export async function readCanonicalTurnSnapshot( if (terminal.kind === 'fact') { const fact = terminal.fact; if (fact.runStatus === 'completed') { - const contextCompactionOutcome = readContextCompactionOutcome( - fact.terminalEvent.actions?.stateDelta?.contextCompactionOutcome, - ); + const candidateContextCompactionOutcome = + fact.terminalEvent.actions?.stateDelta?.contextCompactionOutcome; + const contextCompactionOutcome = isContextCompactionOutcome(candidateContextCompactionOutcome) + ? candidateContextCompactionOutcome + : undefined; return { sessionId, turnId, @@ -110,21 +112,6 @@ export async function readCanonicalTurnSnapshot( return { sessionId, turnId, runId, status: run.status }; } -function readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined { - if (!value || typeof value !== 'object') return undefined; - const outcome = value as Record; - if (outcome.kind === 'compacted' && typeof outcome.checkpointId === 'string') { - return { kind: 'compacted', checkpointId: outcome.checkpointId }; - } - if ( - (outcome.kind === 'unchanged' || outcome.kind === 'failed') && - typeof outcome.reason === 'string' - ) { - return { kind: outcome.kind, reason: outcome.reason }; - } - return undefined; -} - /** Maximizes the encoded size of a protocol-valid failed Turn snapshot. */ export function worstCaseFailedTurnSnapshot(identity: CanonicalTurnIdentity): TurnSnapshot { return { diff --git a/packages/runtime/src/__tests__/conversation-copy.test.ts b/packages/runtime/src/__tests__/conversation-copy.test.ts index 76346cf745..2006e9ba65 100644 --- a/packages/runtime/src/__tests__/conversation-copy.test.ts +++ b/packages/runtime/src/__tests__/conversation-copy.test.ts @@ -578,6 +578,40 @@ test('conversation copy slices exact turns on inclusive and exclusive boundaries assert.equal(createConversationCopySlice(messages, 'missing', 'through'), null); }); +test('conversation copy retains terminal state for a context-compaction-only turn', () => { + const messages: StoredMessage[] = [ + { type: 'user', id: 'user-1', turnId: 'turn-1', ts: 1, text: 'first' }, + { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-1', + ts: 2, + status: 'completed', + partialOutputRetained: true, + }, + { + type: 'turn_state', + id: 'state-compact', + turnId: 'turn-compact', + ts: 3, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + partialOutputRetained: false, + }, + { type: 'user', id: 'user-2', turnId: 'turn-2', ts: 4, text: 'second' }, + ]; + + const slice = createConversationCopySlice(messages, 'turn-compact', 'through'); + + assert.deepEqual(slice?.turnIds, ['turn-1', 'turn-compact']); + assert.deepEqual( + slice?.messages.map((message) => message.id), + ['user-1', 'state-compact'], + ); + assert.equal(slice?.beforeTs, 4); +}); + test('conversation copy rewrites owned references without changing opaque tool payloads', () => { const resourceRef = buildToolResultArchiveResourceRef({ artifactId: 'artifact-source', @@ -986,6 +1020,393 @@ test('conversation copy rejects a retained AgentRun without RuntimeEvent facts', } }); +test('conversation copy rewrites context-compaction outcomes to the rebuilt checkpoint', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-compaction-outcome-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const sourceRun = agentRunHeader({ + runId: 'run-compact-source', + invocationId: 'invocation-compact-source', + turnId: 'turn-compact', + rootExecutionKind: 'context_compact', + cwd: root, + }); + await runStore.createRun(sourceRun); + const contentEvent = runtimeEvent({ + id: 'event-content-source', + invocationId: sourceRun.invocationId, + runId: sourceRun.runId, + turnId: sourceRun.turnId, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'compact this history' }, + }); + const sourceCheckpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: [contentEvent], + summary: 'The user asked to compact this history.', + summaryFormat: 'legacy_freeform', + highWaterSeq: 1, + }); + const terminalEvent = runtimeEvent({ + id: 'event-terminal-source', + invocationId: sourceRun.invocationId, + runId: sourceRun.runId, + turnId: sourceRun.turnId, + ts: 2, + status: 'completed', + actions: { + endInvocation: true, + stateDelta: { + contextCompactionOutcome: { + kind: 'compacted', + checkpointId: sourceCheckpoint.checkpointId, + }, + }, + }, + }); + for (const event of [contentEvent, terminalEvent]) { + await runtimeEventStore.appendRuntimeEvent('session-source', sourceRun.runId, event); + } + await runStore.appendEvent('session-source', sourceRun.runId, { + type: 'history_compact_checkpoint_recorded', + id: 'checkpoint-event-source', + runId: sourceRun.runId, + sessionId: 'session-source', + turnId: sourceRun.turnId, + ts: 1.5, + data: { + checkpointId: sourceCheckpoint.checkpointId, + highWaterName: sourceCheckpoint.highWaterName, + highWaterSeq: sourceCheckpoint.highWaterSeq, + boundaryKind: 'historyCompact', + checkpoint: sourceCheckpoint, + }, + }); + await runStore.appendEvent('session-source', sourceRun.runId, { + type: 'run_completed', + id: 'completed-source', + runId: sourceRun.runId, + sessionId: 'session-source', + turnId: sourceRun.turnId, + ts: 2, + }); + const source = await new RuntimeReadModel({ runStore, runtimeEventStore }).getSessionView( + 'session-source', + ); + let sequence = 0; + + const copied = await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => `target-${++sequence}`, + }); + + const projectedCheckpoint = await runStore.readEventProjection?.( + 'session-target', + 'history_compact_checkpoint_recorded', + ); + assert.ok(projectedCheckpoint); + const targetCheckpointId = projectedCheckpoint.data?.checkpointId; + assert.equal(typeof targetCheckpointId, 'string'); + assert.notEqual(targetCheckpointId, sourceCheckpoint.checkpointId); + const [targetRun] = await runStore.listSessionRuns('session-target'); + assert.ok(targetRun); + const targetTerminal = ( + await runtimeEventStore.readRuntimeEvents('session-target', targetRun.runId) + ).find((event) => event.status === 'completed'); + assert.deepEqual(targetTerminal?.actions?.stateDelta?.contextCompactionOutcome, { + kind: 'compacted', + checkpointId: targetCheckpointId, + }); + const copiedState = copied.copiedMessages.find( + (message) => message.type === 'turn_state' && message.turnId === sourceRun.turnId, + ); + assert.deepEqual( + copiedState?.type === 'turn_state' ? copiedState.contextCompactionOutcome : undefined, + { kind: 'compacted', checkpointId: targetCheckpointId }, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('conversation copy preserves a provider-native compaction outcome without exporting its checkpoint', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-provider-compaction-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const sourceRun = agentRunHeader({ + runId: 'run-compact-source', + invocationId: 'invocation-compact-source', + turnId: 'turn-compact', + rootExecutionKind: 'context_compact', + cwd: root, + }); + await runStore.createRun(sourceRun); + const contentEvent = runtimeEvent({ + id: 'event-content-source', + invocationId: sourceRun.invocationId, + runId: sourceRun.runId, + turnId: sourceRun.turnId, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'compact this history with Codex' }, + }); + const sourceCheckpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: [contentEvent], + providerState: { + kind: 'openai_codex_remote_v2', + connectionSlug: 'codex-source', + modelId: 'gpt-5-codex', + itemId: 'cmp-source', + encryptedContent: 'OPAQUE_SOURCE_COMPACTION_STATE', + }, + highWaterSeq: 1, + }); + const terminalEvent = runtimeEvent({ + id: 'event-terminal-source', + invocationId: sourceRun.invocationId, + runId: sourceRun.runId, + turnId: sourceRun.turnId, + ts: 2, + status: 'completed', + actions: { + endInvocation: true, + stateDelta: { + contextCompactionOutcome: { + kind: 'compacted', + checkpointId: sourceCheckpoint.checkpointId, + }, + }, + }, + }); + for (const event of [contentEvent, terminalEvent]) { + await runtimeEventStore.appendRuntimeEvent('session-source', sourceRun.runId, event); + } + await runStore.appendEvent('session-source', sourceRun.runId, { + type: 'history_compact_checkpoint_recorded', + id: 'checkpoint-event-source', + runId: sourceRun.runId, + sessionId: 'session-source', + turnId: sourceRun.turnId, + ts: 1.5, + data: { + checkpointId: sourceCheckpoint.checkpointId, + highWaterName: sourceCheckpoint.highWaterName, + highWaterSeq: sourceCheckpoint.highWaterSeq, + boundaryKind: 'historyCompact', + checkpoint: sourceCheckpoint, + }, + }); + await runStore.appendEvent('session-source', sourceRun.runId, { + type: 'run_completed', + id: 'completed-source', + runId: sourceRun.runId, + sessionId: 'session-source', + turnId: sourceRun.turnId, + ts: 2, + }); + const source = await new RuntimeReadModel({ runStore, runtimeEventStore }).getSessionView( + 'session-source', + ); + let sequence = 0; + + const copied = await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => `target-${++sequence}`, + }); + + assert.equal( + await runStore.readEventProjection?.('session-target', 'history_compact_checkpoint_recorded'), + null, + ); + const [targetRun] = await runStore.listSessionRuns('session-target'); + assert.ok(targetRun); + const targetTerminal = ( + await runtimeEventStore.readRuntimeEvents('session-target', targetRun.runId) + ).find((event) => event.status === 'completed'); + assert.deepEqual(targetTerminal?.actions?.stateDelta?.contextCompactionOutcome, { + kind: 'compacted', + checkpointId: null, + }); + const copiedState = copied.copiedMessages.find( + (message) => message.type === 'turn_state' && message.turnId === sourceRun.turnId, + ); + assert.deepEqual( + copiedState?.type === 'turn_state' ? copiedState.contextCompactionOutcome : undefined, + { kind: 'compacted', checkpointId: null }, + ); + assert.doesNotMatch( + JSON.stringify(await runStore.readEvents('session-target', targetRun.runId)), + /OPAQUE_SOURCE_COMPACTION_STATE/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('conversation copy drops a provider-native predecessor from a rebuilt checkpoint identity', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-conversation-provider-predecessor-copy-')); + try { + const runStore = createSqliteAgentRunStore(root); + const runtimeEventStore = createWorkspaceRuntimeStore(root); + const sourceRun = agentRunHeader({ + runId: 'run-source', + invocationId: 'invocation-source', + turnId: 'turn-1', + cwd: root, + }); + await runStore.createRun(sourceRun); + const sourceEvents = [ + runtimeEvent({ + id: 'event-user-source', + invocationId: sourceRun.invocationId, + runId: sourceRun.runId, + turnId: sourceRun.turnId, + role: 'user', + author: 'user', + content: { kind: 'text', text: 'copy this checkpoint chain' }, + }), + runtimeEvent({ + id: 'event-terminal-source', + invocationId: sourceRun.invocationId, + runId: sourceRun.runId, + turnId: sourceRun.turnId, + ts: 3, + status: 'completed', + }), + ]; + for (const event of sourceEvents) { + await runtimeEventStore.appendRuntimeEvent('session-source', sourceRun.runId, event); + } + const compactableSourceEvents = sourceEvents.filter(isHistoryCompactContentEvent); + const providerCheckpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: compactableSourceEvents, + providerState: { + kind: 'openai_codex_remote_v2', + connectionSlug: 'codex-source', + modelId: 'gpt-5-codex', + itemId: 'cmp-source', + encryptedContent: 'OPAQUE_SOURCE_COMPACTION_STATE', + }, + highWaterSeq: 1, + }); + const textCheckpoint = buildHistoryCompactCheckpoint({ + sessionId: 'session-source', + coveredRuntimeEvents: compactableSourceEvents, + summary: 'The source checkpoint was rebuilt as portable text.', + summaryFormat: 'legacy_freeform', + highWaterSeq: 2, + previousCheckpointId: providerCheckpoint.checkpointId, + }); + for (const [id, ts, checkpoint] of [ + ['provider-checkpoint-source', 1.5, providerCheckpoint], + ['text-checkpoint-source', 2, textCheckpoint], + ] as const) { + await runStore.appendEvent('session-source', sourceRun.runId, { + type: 'history_compact_checkpoint_recorded', + id, + runId: sourceRun.runId, + sessionId: 'session-source', + turnId: sourceRun.turnId, + ts, + data: { + checkpointId: checkpoint.checkpointId, + highWaterName: checkpoint.highWaterName, + highWaterSeq: checkpoint.highWaterSeq, + boundaryKind: 'historyCompact', + checkpoint, + }, + }); + } + await runStore.appendEvent('session-source', sourceRun.runId, { + type: 'run_completed', + id: 'completed-source', + runId: sourceRun.runId, + sessionId: 'session-source', + turnId: sourceRun.turnId, + ts: 3, + }); + const source = await new RuntimeReadModel({ runStore, runtimeEventStore }).getSessionView( + 'session-source', + ); + let sequence = 0; + + await cloneConversationRuntimeLedger({ + plan: await prepareTestCopyPlan(source, source.messages, runStore, runtimeEventStore), + copiedMessages: source.messages, + referenceMap: { + mode: 'exact', + linkedChildren: { mode: 'reject' }, + sourceSessionId: 'session-source', + targetSessionId: 'session-target', + artifactIds: new Map(), + relativePaths: new Map(), + }, + runStore, + runtimeEventStore, + newId: () => `target-${++sequence}`, + }); + + const targetRun = (await runStore.listSessionRuns('session-target'))[0]; + assert.ok(targetRun); + const targetEvents = await runtimeEventStore.readRuntimeEvents( + 'session-target', + targetRun.runId, + ); + const expected = buildHistoryCompactCheckpoint({ + sessionId: 'session-target', + coveredRuntimeEvents: targetEvents.filter(isHistoryCompactContentEvent), + summary: textCheckpoint.summary, + summaryFormat: 'legacy_freeform', + highWaterName: textCheckpoint.highWaterName, + highWaterSeq: textCheckpoint.highWaterSeq, + now: textCheckpoint.createdAt, + }); + const projectedCheckpoint = await runStore.readEventProjection?.( + 'session-target', + 'history_compact_checkpoint_recorded', + ); + assert.ok(projectedCheckpoint); + assert.ok( + validateHistoryCompactCheckpointShape(projectedCheckpoint.data?.checkpoint, 'session-target'), + ); + assert.equal(projectedCheckpoint.data.checkpoint.previousCheckpointId, undefined); + assert.equal(projectedCheckpoint.data.checkpoint.checkpointId, expected.checkpointId); + assert.doesNotMatch( + JSON.stringify(await runStore.readEvents('session-target', targetRun.runId)), + /OPAQUE_SOURCE_COMPACTION_STATE/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('conversation copy rewrites a complete tool recovery bundle atomically', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-conversation-recovery-copy-')); const runStore = createSqliteAgentRunStore(root); diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index b172e83b1b..967603c636 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1445,6 +1445,48 @@ describe('projectRuntimeEventsToStoredMessages', () => { expect(out.diagnostics).toEqual([]); }); + test('context compaction projects its typed outcome onto the terminal turn state', () => { + const out = projectRuntimeEventsToStoredMessages( + [ + ev({ + id: 'evt-compacted', + ts: ts + 9, + status: 'completed', + actions: { + endInvocation: true, + stateDelta: { + contextCompactionOutcome: { + kind: 'compacted', + checkpointId: 'checkpoint-1', + }, + }, + }, + }), + ], + { + runHeaders: [{ ...header, rootExecutionKind: 'context_compact' }], + }, + ); + + expect(out.messages).toEqual([ + { + type: 'turn_state', + id: 'evt-compacted', + turnId, + ts: ts + 9, + status: 'completed', + parentTurnId: 'parent-turn', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { + kind: 'compacted', + checkpointId: 'checkpoint-1', + }, + partialOutputRetained: false, + }, + ]); + expect(out.diagnostics).toEqual([]); + }); + test('tool step cap terminal fact projects a persistent system notice', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts index f3da347e0b..37393bc7dc 100644 --- a/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts +++ b/packages/runtime/src/__tests__/runtime-ledger-repair.test.ts @@ -353,6 +353,107 @@ test('an imported turn with no terminal state is repaired to failed', async () = } }); +test('enriches a legacy context-compaction terminal state with its typed outcome', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-context-compaction-repair-')); + const sessions = createSessionStore(root); + const runs = createSqliteAgentRunStore(root); + const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite')); + + try { + const session = await sessions.create({ + cwd: '/repo', + llmConnectionSlug: 'anthropic', + model: 'claude-opus-5', + permissionMode: 'ask', + }); + const run = await runs.createRun({ + runId: 'run-compact', + invocationId: 'invocation-compact', + sessionId: session.id, + turnId: 'turn-compact', + status: 'completed', + backendKind: session.backend, + llmConnectionSlug: session.llmConnectionSlug, + modelId: session.model, + cwd: session.cwd, + permissionMode: session.permissionMode, + rootExecutionKind: 'context_compact', + createdAt: 1, + updatedAt: 2, + completedAt: 2, + }); + await sessions.appendMessage(session.id, { + type: 'turn_state', + id: 'compact-legacy-terminal', + turnId: run.turnId, + ts: 2, + status: 'completed', + partialOutputRetained: false, + }); + await runtimeEvents.appendRuntimeEvent(session.id, run.runId, { + id: 'compact-terminal', + invocationId: run.invocationId!, + runId: run.runId, + sessionId: session.id, + turnId: run.turnId, + ts: 2, + partial: false, + role: 'system', + author: 'system', + status: 'completed', + actions: { + endInvocation: true, + stateDelta: { + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + }, + }, + }); + + const repair = new RuntimeLedgerRepair({ + runStore: runs, + runtimeEventStore: runtimeEvents, + readMessages: (sessionId) => sessions.readMessages(sessionId), + appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message), + appendTurnState: async (sessionId, turnId, status, lineage, options) => { + await sessions.appendMessage(sessionId, { + type: 'turn_state', + id: 'compact-repaired', + turnId, + ts: options?.ts ?? 3, + status, + ...lineage, + ...(options?.errorClass ? { errorClass: options.errorClass } : {}), + ...(options?.abortSource ? { abortSource: options.abortSource } : {}), + ...(options?.rootExecutionKind ? { rootExecutionKind: options.rootExecutionKind } : {}), + ...(options?.contextCompactionOutcome + ? { contextCompactionOutcome: options.contextCompactionOutcome } + : {}), + partialOutputRetained: false, + }); + }, + newId: () => 'repair-id', + now: () => 3, + }); + + assert.equal(await repair.repairMissingTerminalFactOnce(session.id, run.runId), true); + assert.deepEqual((await sessions.readMessages(session.id)).at(-1), { + type: 'turn_state', + id: 'compact-repaired', + turnId: run.turnId, + ts: 2, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + partialOutputRetained: false, + }); + } finally { + runtimeEvents.close(); + runs.close?.(); + await sessions.close?.(); + await rm(root, { recursive: true, force: true }); + } +}); + test('a resolved Claude transcript replays as the conversation the user kept', async () => { // The whole path, end to end: raw records → lineage resolution → conversion // → Ledger materialization → the replay a continuation would be given. diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index a3c83a20ea..02cfd15350 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -3928,6 +3928,15 @@ describe('SessionManager manual compaction and quiescent session changes', () => message.status === 'completed', ), ).toBe(true); + const compactStates = messages.filter( + (message): message is Extract => + message.type === 'turn_state' && message.turnId === 'turn-compact', + ); + expect(compactStates.map((message) => message.status)).toEqual(['running', 'completed']); + expect(compactStates.every((message) => message.rootExecutionKind === 'context_compact')).toBe( + true, + ); + expect(compactStates.at(-1)?.contextCompactionOutcome?.kind).toBe('compacted'); const compactRun = (await runStore.listSessionRuns(session.id)).find( (run) => run.turnId === 'turn-compact', @@ -4082,6 +4091,40 @@ describe('SessionManager manual compaction and quiescent session changes', () => expect(warnings).toHaveLength(1); }); + test('rejects a copied-history-only null checkpoint from a live compaction backend', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const backends = new BackendRegistry(); + backends.register('ai-sdk', (ctx) => new NullCheckpointCompactingBackend(ctx)); + const manager = new SessionManager({ + store, + runStore, + runtimeEventStore: runStore, + backends, + newId: nextId(), + now: nextNow(13_000), + }); + const session = await manager.createSession(makeInput({ permissionMode: 'bypass' })); + + const error = await collectSessionEvents( + manager.compactSession(session.id, { turnId: 'turn-compact' }), + ).catch((caught: unknown) => caught); + + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toContain('invalid live compaction outcome'); + const compactRun = (await runStore.listSessionRuns(session.id)).find( + (run) => run.turnId === 'turn-compact', + ); + expect(compactRun?.status).toBe('failed'); + expect( + (await runStore.readRuntimeEvents(session.id, compactRun!.runId)).some( + (event) => + (event.actions?.stateDelta?.contextCompactionOutcome as { kind?: unknown } | undefined) + ?.kind === 'compacted', + ), + ).toBe(false); + }); + test('manual compaction stopped before backend start does not write compact artifacts', async () => { const store = new MemorySessionStore(); const readGate = makeGate(); @@ -4288,6 +4331,16 @@ describe('SessionManager manual compaction and quiescent session changes', () => (run) => run.turnId === 'turn-compact', ); expect(compactRun?.status).toBe('cancelled'); + const compactState = (await store.readMessages(session.id)) + .filter( + (message): message is Extract => + message.type === 'turn_state' && message.turnId === 'turn-compact', + ) + .at(-1); + expect(compactState).toMatchObject({ + status: 'aborted', + rootExecutionKind: 'context_compact', + }); }); test('compactSession rejects while a turn is running and writes no compact artifacts', async () => { @@ -7771,6 +7824,68 @@ describe('SessionManager permission mode updates', () => { ]); }); + test('RuntimeReadModel diagnoses a terminal compaction state missing only its outcome', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const session = await store.create(makeInput()); + await store.appendMessage(session.id, { + type: 'turn_state', + id: 'legacy-compact-state', + turnId: 'turn-compact', + ts: 103, + status: 'completed', + rootExecutionKind: 'context_compact', + partialOutputRetained: false, + }); + await seedRuntimeRun( + runStore, + makeRunHeader({ + sessionId: session.id, + runId: 'run-compact', + turnId: 'turn-compact', + status: 'completed', + rootExecutionKind: 'context_compact', + createdAt: 100, + updatedAt: 103, + completedAt: 103, + }), + [ + runtimeEvent({ + id: 'rt-compact-complete', + sessionId: session.id, + runId: 'run-compact', + turnId: 'turn-compact', + ts: 103, + status: 'completed', + actions: { + endInvocation: true, + stateDelta: { + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + }, + }, + }), + ], + ); + + const view = await new RuntimeReadModel({ + runStore, + runtimeEventStore: runStore, + projectionCache: store, + }).getSessionView(session.id); + + assert.deepEqual( + view.diagnostics.filter((diagnostic) => diagnostic.code === 'stale_terminal_projection'), + [ + { + code: 'stale_terminal_projection', + runId: 'run-compact', + turnId: 'turn-compact', + message: 'terminal turn_state is missing RuntimeEvent semantic fields', + }, + ], + ); + }); + test('SessionManager projects hosted permission details from the canonical outcome', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -8132,6 +8247,129 @@ describe('SessionManager permission mode updates', () => { ]); }); + test('getMessages durably enriches a terminal compaction state missing only its outcome', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const manager = makeManagerForReadCutover(store, runStore); + const session = await manager.createSession(makeInput()); + await store.appendMessage(session.id, { + type: 'turn_state', + id: 'legacy-compact-state', + turnId: 'turn-compact', + ts: 103, + status: 'completed', + rootExecutionKind: 'context_compact', + partialOutputRetained: false, + }); + await seedRuntimeRun( + runStore, + makeRunHeader({ + sessionId: session.id, + runId: 'run-compact', + turnId: 'turn-compact', + status: 'completed', + rootExecutionKind: 'context_compact', + createdAt: 100, + updatedAt: 103, + completedAt: 103, + }), + [ + runtimeEvent({ + id: 'rt-compact-complete', + sessionId: session.id, + runId: 'run-compact', + turnId: 'turn-compact', + ts: 103, + status: 'completed', + actions: { + endInvocation: true, + stateDelta: { + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + }, + }, + }), + ], + ); + + expect(await manager.getMessages(session.id)).toEqual([ + { + type: 'turn_state', + id: 'rt-compact-complete', + turnId: 'turn-compact', + ts: 103, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + partialOutputRetained: false, + }, + ]); + expect((await store.readMessages(session.id)).at(-1)).toMatchObject({ + type: 'turn_state', + turnId: 'turn-compact', + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + }); + expect(await store.readMessages(session.id)).toHaveLength(2); + }); + + test('startup recovery durably enriches a legacy terminal compaction state', async () => { + const store = new MemorySessionStore(); + const runStore = new MemoryAgentRunStore(); + const manager = makeManagerForReadCutover(store, runStore); + const session = await manager.createSession(makeInput()); + await store.appendMessage(session.id, { + type: 'turn_state', + id: 'legacy-compact-state', + turnId: 'turn-compact', + ts: 103, + status: 'completed', + partialOutputRetained: false, + }); + await seedRuntimeRun( + runStore, + makeRunHeader({ + sessionId: session.id, + runId: 'run-compact', + turnId: 'turn-compact', + status: 'completed', + rootExecutionKind: 'context_compact', + createdAt: 100, + updatedAt: 103, + completedAt: 103, + }), + [ + runtimeEvent({ + id: 'rt-compact-complete', + sessionId: session.id, + runId: 'run-compact', + turnId: 'turn-compact', + ts: 103, + status: 'completed', + actions: { + endInvocation: true, + stateDelta: { + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + }, + }, + }), + ], + ); + + expect(await manager.recoverInterruptedSessions()).toEqual([session.id]); + expect((await store.readMessages(session.id)).at(-1)).toMatchObject({ + type: 'turn_state', + turnId: 'turn-compact', + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'unchanged', reason: 'already_compacted' }, + }); + expect(await store.readMessages(session.id)).toHaveLength(2); + + expect(await manager.recoverInterruptedSessions()).toEqual([]); + expect(await store.readMessages(session.id)).toHaveLength(2); + }); + test('getMessages repairs a non-empty RuntimeEvent ledger that is missing only the terminal fact', async () => { const store = new MemorySessionStore(); const runStore = new MemoryAgentRunStore(); @@ -16286,6 +16524,14 @@ class FailOpenCompactingBackend extends TestBackend { } } +class NullCheckpointCompactingBackend extends TestBackend { + async compactHistory(_input: { turnId: string; runtimeContext: readonly RuntimeEvent[] }) { + return { + outcome: { kind: 'compacted' as const, checkpointId: null }, + } as unknown as ReturnType; + } +} + class ActiveTurnBackend extends TestBackend { constructor( ctx: BackendFactoryContext, diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index fcbf0102ad..22f1508d94 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -58,7 +58,7 @@ import { resolveEffectiveOrchestration, type EffectiveOrchestration, } from '@maka/core/orchestration'; -import type { SessionEvent } from '@maka/core/events'; +import type { ContextCompactionOutcome, SessionEvent } from '@maka/core/events'; import type { AgentBackend, BackendSendInput } from '@maka/core/backend-types'; import type { RunTraceEvent } from './run-trace.js'; import type { StopSessionInput } from './session-manager.js'; @@ -119,7 +119,13 @@ export interface AgentRunHooks { turnId: string, status: TurnRecord['status'], lineage?: AgentRunLineage, - options?: { ts?: number; errorClass?: string; abortSource?: string }, + options?: { + ts?: number; + errorClass?: string; + abortSource?: string; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + contextCompactionOutcome?: ContextCompactionOutcome; + }, ): Promise; } @@ -225,6 +231,10 @@ export class AgentRun { readonly effectiveOrchestration: EffectiveOrchestration; readonly toolMode: ToolMode; + get rootExecutionKind(): AgentRunHeader['rootExecutionKind'] { + return this.input.rootExecutionKind; + } + private readonly input: AgentRunInput; private header: SessionHeader; private active: AgentRunActiveSession | undefined; @@ -727,6 +737,9 @@ export class AgentRun { if (this.recordsSessionMessages()) { await this.input.hooks.appendTurnState(this.sessionId, this.turnId, 'running', this.lineage, { ts: startedAt, + ...(this.input.rootExecutionKind + ? { rootExecutionKind: this.input.rootExecutionKind } + : {}), }); } @@ -914,6 +927,12 @@ export class AgentRun { ...(turnStatus.status === 'aborted' && this.abortSource ? { abortSource: this.abortSource } : {}), + ...(this.input.rootExecutionKind + ? { rootExecutionKind: this.input.rootExecutionKind } + : {}), + ...(ev.type === 'complete' && ev.contextCompactionOutcome + ? { contextCompactionOutcome: ev.contextCompactionOutcome } + : {}), }, ); if (terminalSessionEvent || ev.type === 'error') { @@ -935,6 +954,9 @@ export class AgentRun { .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { ts: ev.ts, errorClass: ev.reason ?? ev.code ?? 'unknown', + ...(this.input.rootExecutionKind + ? { rootExecutionKind: this.input.rootExecutionKind } + : {}), }) .catch((error) => this.enqueueTraceWriteFailure(error, 'terminal session projection')); } @@ -1050,6 +1072,9 @@ export class AgentRun { await this.input.hooks .appendTurnState(this.sessionId, this.turnId, 'failed', this.lineage, { errorClass: error instanceof Error ? error.name : 'unknown', + ...(this.input.rootExecutionKind + ? { rootExecutionKind: this.input.rootExecutionKind } + : {}), }) .catch(() => {}); } diff --git a/packages/runtime/src/conversation-copy.ts b/packages/runtime/src/conversation-copy.ts index d013f0b7d7..5452c41964 100644 --- a/packages/runtime/src/conversation-copy.ts +++ b/packages/runtime/src/conversation-copy.ts @@ -25,7 +25,12 @@ import type { } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; -import type { StorageRef, ToolResultContent } from '@maka/core/events'; +import { + isContextCompactionOutcome, + type ContextCompactionOutcome, + type StorageRef, + type ToolResultContent, +} from '@maka/core/events'; import { markPersisted } from '@maka/core/persisted-value'; import type { StoredMessage } from '@maka/core/session'; import { decodePersistedToolResultContent } from '@maka/core/tool-result-record-schema'; @@ -177,7 +182,9 @@ export function createConversationCopySlice( .map((message) => message.ts); return { messages: messages.filter((message) => { - if (message.type === 'turn_state') return false; + if (message.type === 'turn_state' && message.rootExecutionKind !== 'context_compact') { + return false; + } const turnId = messageTurnId(message); return turnId !== undefined && retained.has(turnId); }), @@ -191,7 +198,21 @@ export function createConversationCopySlice( export function rewriteConversationCopyMessage( message: StoredMessage, references: ConversationCopyMessageReferenceMap, + checkpointIds?: ReadonlyMap, ): StoredMessage { + if ( + message.type === 'turn_state' && + message.contextCompactionOutcome?.kind === 'compacted' && + checkpointIds + ) { + return { + ...message, + contextCompactionOutcome: rewriteContextCompactionOutcome( + message.contextCompactionOutcome, + checkpointIds, + ), + }; + } if (message.type === 'user' && message.attachments) { return { ...message, @@ -351,7 +372,7 @@ export async function cloneConversationRuntimeLedger( ); } } - const checkpointIds = new Map(); + const checkpointIds = new Map(); const preparedPlans = flattenedPlans.map((plan) => { const runId = runIds.get(plan.run.runId)!; const invocationId = targetInvocationIds.get(plan.run.runId)!; @@ -394,8 +415,11 @@ export async function cloneConversationRuntimeLedger( terminalEvent, }; }); + for (const event of clonedEventBySourceId.values()) { + rewriteRuntimeEventContextCompactionOutcome(event, checkpointIds); + } const copiedMessages = input.copiedMessages.map((message) => - rewriteConversationCopyMessage(message, references), + rewriteConversationCopyMessage(message, references, checkpointIds), ); for (const { clonedRun } of preparedPlans) { @@ -626,7 +650,7 @@ function cloneAgentRunEvent( references: ConversationCopyReferenceMap, sourceCompactableEvents: readonly RuntimeEvent[], clonedRuntimeEvents: ReadonlyMap, - checkpointIds: Map, + checkpointIds: Map, operationalEventIds: ReadonlyMap, providerTraceIds: ReadonlyMap, ): EmittedAgentRunEvent | null { @@ -654,10 +678,14 @@ function cloneAgentRunEvent( if (!validateHistoryCompactCheckpointShape(sourceCheckpoint, event.sessionId)) { throw new Error(`Cannot copy invalid history compact checkpoint ${event.id}`); } - // Conversation copies carry the canonical raw RuntimeEvents and can create - // a fresh checkpoint on demand. Do not export opaque provider state into a - // new session or degrade it into user-visible placeholder text. - if (sourceCheckpoint.version === 3) return null; + // Provider-native checkpoints are bound to the source provider/session and + // carry opaque state. Preserve the historical "compacted" outcome, but + // mark this checkpoint as intentionally unavailable in the target instead + // of exporting the provider state or leaving a dangling source id. + if (sourceCheckpoint.version === 3) { + checkpointIds.set(sourceCheckpoint.checkpointId, null); + return null; + } const match = matchHistoryCompactCheckpointPrefix(sourceCheckpoint, sourceCompactableEvents); if (match.reason) { throw new Error(`Cannot copy unmatched history compact checkpoint ${event.id}`); @@ -705,7 +733,7 @@ function cloneAgentRunEvent( ...(sourceCheckpoint.phase ? { phase: sourceCheckpoint.phase } : {}), ...(headAnchor ? { headAnchor } : {}), ...(sourceCheckpoint.previousCheckpointId && - checkpointIds.has(sourceCheckpoint.previousCheckpointId) + checkpointIds.get(sourceCheckpoint.previousCheckpointId) ? { previousCheckpointId: checkpointIds.get(sourceCheckpoint.previousCheckpointId)!, } @@ -1093,6 +1121,40 @@ function rewriteRuntimeEventActions( }; } +function rewriteRuntimeEventContextCompactionOutcome( + event: RuntimeEvent, + checkpointIds: ReadonlyMap, +): void { + const stateDelta = event.actions?.stateDelta; + const outcome = stateDelta?.contextCompactionOutcome; + if (!isContextCompactionOutcome(outcome) || outcome.kind !== 'compacted') return; + event.actions = { + ...event.actions, + stateDelta: { + ...stateDelta, + contextCompactionOutcome: rewriteContextCompactionOutcome(outcome, checkpointIds), + }, + }; +} + +function rewriteContextCompactionOutcome( + outcome: Extract, + checkpointIds: ReadonlyMap, +): Extract { + if (outcome.checkpointId === null) return outcome; + const targetCheckpointId = checkpointIds.get(outcome.checkpointId); + if (targetCheckpointId === null) return { kind: 'compacted', checkpointId: null }; + if (!targetCheckpointId) { + throw new Error( + `Conversation copy is missing history compact checkpoint ${outcome.checkpointId}`, + ); + } + return { + kind: 'compacted', + checkpointId: targetCheckpointId, + }; +} + function rewriteToolRecoveryFact( recovery: NonNullable['toolRecovery'], operationId: string, diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index 044fdfb8e2..fff059d2d7 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -465,6 +465,9 @@ function terminalRuntimeEvent(input: { ...terminalRecoveryState(input.now, turnState), ...(failureClass !== undefined ? { failureClass, errorClass: failureClass } : {}), ...(abortSource !== undefined ? { abortSource } : {}), + ...(turnState?.contextCompactionOutcome + ? { contextCompactionOutcome: turnState.contextCompactionOutcome } + : {}), }, }, ...(turnState ? { refs: { storedMessageId: turnState.id } } : {}), diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index efa00c2c8c..a5ad44bbab 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -27,7 +27,11 @@ import { validateSandboxBoundaryExpansion, } from '@maka/core/sandbox-boundary'; -import { TOOL_ACTIVITY_KINDS, normalizeMessageContent } from '@maka/core/events'; +import { + isContextCompactionOutcome, + TOOL_ACTIVITY_KINDS, + normalizeMessageContent, +} from '@maka/core/events'; import { isPartialRuntimeEvent, @@ -58,7 +62,8 @@ export type RuntimeEventReadModelDiagnosticCode = | 'generated_id' | 'tool_use_id_mismatch' | 'missing_legacy_message' - | 'unexpected_projected_message'; + | 'unexpected_projected_message' + | 'stale_terminal_projection'; /** * Whether a diagnostic means the projection may have lost user-visible content. @@ -84,6 +89,7 @@ const RUNTIME_EVENT_READ_MODEL_DIAGNOSTIC_SEVERITY: Record< tool_use_id_mismatch: 'hard', missing_legacy_message: 'soft', unexpected_projected_message: 'soft', + stale_terminal_projection: 'soft', }; export function isHardRuntimeEventReadModelDiagnostic(diagnostic: { @@ -1140,6 +1146,10 @@ function projectTerminalTurnState( const abortSource = status === 'aborted' ? abortSourceFromRuntime(event, header) : undefined; const failureClass = status === 'failed' ? failureClassFromRuntimeEvent(event, header) : undefined; + const candidateContextCompactionOutcome = event.actions?.stateDelta?.contextCompactionOutcome; + const contextCompactionOutcome = isContextCompactionOutcome(candidateContextCompactionOutcome) + ? candidateContextCompactionOutcome + : undefined; const partialOutputRetained = messages.some( (message) => message.turnId === event.turnId && @@ -1162,6 +1172,8 @@ function projectTerminalTurnState( ...(status === 'aborted' ? { abortedAt: event.ts } : {}), ...(abortSource ? { abortSource } : {}), ...(status === 'failed' ? { errorClass: failureClass ?? 'unknown' } : {}), + ...(header.rootExecutionKind ? { rootExecutionKind: header.rootExecutionKind } : {}), + ...(contextCompactionOutcome ? { contextCompactionOutcome } : {}), partialOutputRetained, }); if (failureClass === 'tool_step_cap_reached') { @@ -1573,6 +1585,8 @@ function semanticMessage(message: StoredMessage): unknown { abortedAt: message.abortedAt, abortSource: message.abortSource, errorClass: message.errorClass, + rootExecutionKind: message.rootExecutionKind, + contextCompactionOutcome: message.contextCompactionOutcome, partialOutputRetained: message.partialOutputRetained, }; case 'system_note': diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index a9c0c9fefc..97c35ee8cc 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -33,13 +33,15 @@ import type { RuntimeEventStore, } from '@maka/core/runtime-event-store'; import { isSessionInlineRun } from '@maka/core/agent-run'; -import type { - ActiveInteractionRequestEvent, - CompleteEvent, - QueueEnqueueOutcome, - QueueUpdateEvent, - SessionEvent, - TokenUsageEvent, +import { + isLiveContextCompactionOutcome, + type ActiveInteractionRequestEvent, + type CompleteEvent, + type ContextCompactionOutcome, + type QueueEnqueueOutcome, + type QueueUpdateEvent, + type SessionEvent, + type TokenUsageEvent, } from '@maka/core/events'; import type { SessionBlockedReason, @@ -393,6 +395,7 @@ interface StopOperation { id: string; turnId: string; lineage: AgentRunLineage; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; message?: TurnStateMessage; projected: boolean; } @@ -1089,6 +1092,9 @@ export class RuntimeKernel implements RuntimeKernelLike { runtimeContext: begin.runtimeContext, }); if (run.isStopped()) return; + if (!isLiveContextCompactionOutcome(result.outcome)) { + throw new Error(`Backend ${header.backend} returned an invalid live compaction outcome`); + } const tokenUsageEvent: TokenUsageEvent = { type: 'token_usage', id: this.deps.newId(), @@ -2252,6 +2258,7 @@ export class RuntimeKernel implements RuntimeKernelLike { id: this.deps.newId(), turnId: run.turnId, lineage: run.lineage, + ...(run.rootExecutionKind ? { rootExecutionKind: run.rootExecutionKind } : {}), projected: false, } : undefined; @@ -2378,6 +2385,9 @@ export class RuntimeKernel implements RuntimeKernelLike { status: 'aborted', lineage: projection.lineage, ...(operation.abortSource ? { abortSource: operation.abortSource } : {}), + ...(projection.rootExecutionKind + ? { rootExecutionKind: projection.rootExecutionKind } + : {}), partialOutputRetained: await this.turnHasRetainedOutput(sessionId, projection.turnId), }); await this.appendStopProjection(sessionId, projection.message); @@ -3436,7 +3446,14 @@ export class RuntimeKernel implements RuntimeKernelLike { turnId: string, status: TurnRecord['status'], lineage: AgentRunLineage = {}, - options: { id?: string; ts?: number; errorClass?: string; abortSource?: string } = {}, + options: { + id?: string; + ts?: number; + errorClass?: string; + abortSource?: string; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + contextCompactionOutcome?: ContextCompactionOutcome; + } = {}, ): Promise { const ts = options.ts ?? this.deps.now(); await this.deps.store.appendMessage( @@ -3449,6 +3466,10 @@ export class RuntimeKernel implements RuntimeKernelLike { lineage, ...(options.abortSource ? { abortSource: options.abortSource } : {}), ...(options.errorClass !== undefined ? { errorClass: options.errorClass } : {}), + ...(options.rootExecutionKind ? { rootExecutionKind: options.rootExecutionKind } : {}), + ...(options.contextCompactionOutcome + ? { contextCompactionOutcome: options.contextCompactionOutcome } + : {}), partialOutputRetained: await this.turnHasRetainedOutput(sessionId, turnId), }), ); diff --git a/packages/runtime/src/runtime-ledger-repair.ts b/packages/runtime/src/runtime-ledger-repair.ts index 98e85f4105..6bda618c10 100644 --- a/packages/runtime/src/runtime-ledger-repair.ts +++ b/packages/runtime/src/runtime-ledger-repair.ts @@ -24,11 +24,13 @@ import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; import type { AgentRunHeader, AgentRunStore } from '@maka/core/agent-run'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import type { RuntimeEventStore } from '@maka/core/runtime-event-store'; +import { isContextCompactionOutcome, type ContextCompactionOutcome } from '@maka/core/events'; import type { SessionHeader } from '@maka/core/session'; import type { StoredMessage, TurnRecord } from '@maka/core/session'; import type { AgentRunLineage } from './agent-run.js'; import { backfillRuntimeEventsFromStoredMessages } from './runtime-event-backfill.js'; import { projectRuntimeEventUserMessage } from './runtime-event-read-model.js'; +import { isTerminalTurnStatus, terminalTurnStateMatches } from './session-projection-helpers.js'; import { buildRecoveredTerminalRuntimeEvent, commitTerminalRunWithRuntimeFact, @@ -44,7 +46,13 @@ export interface RuntimeLedgerRepairDeps { turnId: string, status: TurnRecord['status'], lineage?: AgentRunLineage, - options?: { ts?: number; errorClass?: string; abortSource?: string }, + options?: { + ts?: number; + errorClass?: string; + abortSource?: string; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + contextCompactionOutcome?: ContextCompactionOutcome; + }, ): Promise; newId: () => string; now: () => number; @@ -272,6 +280,11 @@ export class RuntimeLedgerRepair { (turnState?.status === 'aborted' ? turnState.abortSource : undefined) ?? 'unknown') : undefined; + const candidateContextCompactionOutcome = + terminal.actions?.stateDelta?.contextCompactionOutcome; + const contextCompactionOutcome = isContextCompactionOutcome(candidateContextCompactionOutcome) + ? candidateContextCompactionOutcome + : undefined; const existingEvents = await this.deps.runStore .readEvents(sessionId, run.runId) .catch(() => []); @@ -312,6 +325,8 @@ export class RuntimeLedgerRepair { ts, ...(failureClass ? { errorClass: failureClass } : {}), ...(abortSource ? { abortSource } : {}), + ...(run.rootExecutionKind ? { rootExecutionKind: run.rootExecutionKind } : {}), + ...(contextCompactionOutcome ? { contextCompactionOutcome } : {}), }, ).catch(() => {}); return true; @@ -387,7 +402,11 @@ export class RuntimeLedgerRepair { lineage: headerLineage(run), }, 'failed', - { ts, errorClass: failureClass }, + { + ts, + errorClass: failureClass, + ...(run.rootExecutionKind ? { rootExecutionKind: run.rootExecutionKind } : {}), + }, ).catch(() => {}); } @@ -397,11 +416,17 @@ export class RuntimeLedgerRepair { run: AgentRunHeader, decision: RuntimeLedgerRepairDecision, status: TurnRecord['status'], - options: { ts: number; errorClass?: string; abortSource?: string }, + options: { + ts: number; + errorClass?: string; + abortSource?: string; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + contextCompactionOutcome?: ContextCompactionOutcome; + }, ): Promise { if (!isSessionInlineRun(run)) return; const latest = latestTurnState(messages, decision.turnId); - if (latest && isTerminalTurnStatus(latest.status) && latest.status === status) return; + if (terminalTurnStateMatches(latest, status, options)) return; await this.deps.appendTurnState(sessionId, decision.turnId, status, decision.lineage, options); } } @@ -442,6 +467,7 @@ function transcriptRunHeader(input: { ...(status === 'cancelled' ? { abortSource: input.turn.abortSource ?? 'external_session_snapshot' } : {}), + ...(input.turn.rootExecutionKind ? { rootExecutionKind: input.turn.rootExecutionKind } : {}), }; } @@ -480,6 +506,7 @@ export function firstRuntimeRepairRunId( for (const diagnostic of diagnostics) { const runId = diagnostic.runId ?? diagnosticDetailRunId(diagnostic.detail); if (!runId || alreadyRepaired.has(runId)) continue; + if (diagnostic.code === 'stale_terminal_projection') return runId; if (diagnostic.code !== 'incomplete_event') continue; if ( diagnostic.message === 'terminal run recovered from legacy projection cache' || @@ -522,10 +549,6 @@ function steeringMessageFromRuntimeEvent(event: RuntimeEvent): StoredMessage | u return projectRuntimeEventUserMessage(event, messageId); } -function isTerminalTurnStatus(status: TurnRecord['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'aborted'; -} - function terminalRunStatusFromEvent( run: AgentRunHeader, event: RuntimeEvent, diff --git a/packages/runtime/src/runtime-read-model.ts b/packages/runtime/src/runtime-read-model.ts index 4a11aa446b..a235b2503d 100644 --- a/packages/runtime/src/runtime-read-model.ts +++ b/packages/runtime/src/runtime-read-model.ts @@ -45,6 +45,7 @@ import { effectiveRunHeaderFromTerminalFact, terminalRunHeaderMatchesFact, } from './terminal-run-commit.js'; +import { isTerminalTurnStatus, terminalTurnStateMatches } from './session-projection-helpers.js'; const CANONICAL_PERMISSION_READ_CONCURRENCY = 8; @@ -348,6 +349,7 @@ export class RuntimeReadModel { diagnostics.push( ...this.compareProjectionCache(messages, cachedMessages, canonicalPermissionRead.outcomes), + ...terminalProjectionRepairDiagnostics(messages, cachedMessages, input.runs), ); return { @@ -424,6 +426,47 @@ export class RuntimeReadModel { } } +function terminalProjectionRepairDiagnostics( + projected: readonly StoredMessage[], + cached: readonly StoredMessage[] | undefined, + runs: readonly AgentRunHeader[], +): RuntimeEventReadModelDiagnostic[] { + if (!cached) return []; + const runsByTurnId = new Map(runs.map((run) => [run.turnId, run] as const)); + const diagnostics: RuntimeEventReadModelDiagnostic[] = []; + for (const message of projected) { + if ( + message.type !== 'turn_state' || + !isTerminalTurnStatus(message.status) || + (message.rootExecutionKind === undefined && message.contextCompactionOutcome === undefined) + ) { + continue; + } + const run = runsByTurnId.get(message.turnId); + if (!run) continue; + const latestCached = latestTurnState(cached, message.turnId); + if (terminalTurnStateMatches(latestCached, message.status, message)) continue; + diagnostics.push({ + code: 'stale_terminal_projection', + runId: run.runId, + turnId: run.turnId, + message: 'terminal turn_state is missing RuntimeEvent semantic fields', + }); + } + return diagnostics; +} + +function latestTurnState( + messages: readonly StoredMessage[], + turnId: string, +): Extract | undefined { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.type === 'turn_state' && message.turnId === turnId) return message; + } + return undefined; +} + /** * The interaction facts an active run must keep even while its messages come * from the in-flight projection cache. Permission prompts were always carried diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 7693840d64..52a906b22b 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -104,7 +104,11 @@ import { } from '@maka/core/session'; import { decodeAgentGraphIntentClaim } from '@maka/core/agent-graph-control'; import { executionBoundaryContains } from '@maka/core/sandbox-boundary'; -import { failureClassFromCompleteStopReason } from '@maka/core/events'; +import { + failureClassFromCompleteStopReason, + isContextCompactionOutcome, + type ContextCompactionOutcome, +} from '@maka/core/events'; import { isActiveShellRunStatus } from '@maka/core/shell-run'; import { isSessionInlineRun } from '@maka/core/agent-run'; import { isTerminalRuntimeEvent } from '@maka/core/runtime-event'; @@ -216,6 +220,8 @@ import { projectAgentGraphRecords } from './stream-graph-projection.js'; import { buildStatusPatch, buildTurnStateMessage, + isTerminalTurnStatus, + terminalTurnStateMatches, turnHasRetainedOutput as messagesHaveRetainedOutput, } from './session-projection-helpers.js'; import { @@ -5372,7 +5378,13 @@ export class SessionManager { turnId: string, status: TurnRecord['status'], lineage: AgentRunLineage = {}, - options: { ts?: number; errorClass?: string; abortSource?: string } = {}, + options: { + ts?: number; + errorClass?: string; + abortSource?: string; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + contextCompactionOutcome?: ContextCompactionOutcome; + } = {}, ): Promise { const ts = options.ts ?? this.deps.now(); await this.deps.store.appendMessage( @@ -5385,6 +5397,10 @@ export class SessionManager { lineage, ...(options.abortSource ? { abortSource: options.abortSource } : {}), ...(options.errorClass !== undefined ? { errorClass: options.errorClass } : {}), + ...(options.rootExecutionKind ? { rootExecutionKind: options.rootExecutionKind } : {}), + ...(options.contextCompactionOutcome + ? { contextCompactionOutcome: options.contextCompactionOutcome } + : {}), partialOutputRetained: await this.turnHasRetainedOutput(sessionId, turnId), }), ); @@ -5783,6 +5799,38 @@ export class SessionManager { } continue; } + if ( + isTerminalRunStatus(run.status) && + inspected.terminalRuntimeFact && + (run.rootExecutionKind !== undefined || + inspected.terminalRuntimeFact.terminalEvent.actions?.stateDelta + ?.contextCompactionOutcome !== undefined) + ) { + const messages = await recoverOr( + policy, + () => this.deps.store.readMessages(sessionId), + [] as StoredMessage[], + ); + const projectedTerminal = inspected.projection?.messages.find( + (message): message is Extract => + message.type === 'turn_state' && + message.turnId === run.turnId && + isTerminalTurnStatus(message.status), + ); + const latest = latestTurnState(messages, run.turnId); + if ( + projectedTerminal && + !terminalTurnStateMatches(latest, projectedTerminal.status, projectedTerminal) + ) { + const repaired = await this.repairMissingTerminalFactOnce(sessionId, run.runId); + if (repaired) { + recovered = true; + } else if (policy.kind === 'strict') { + throw new Error(`Unable to repair terminal transcript state for run ${run.runId}`); + } + continue; + } + } const runtimeDecision = this.classifyRuntimeEventRecovery(inspected); const classified = runtimeDecision ?? classifyAgentRunRecovery(run, inspected.events); if (!classified) continue; @@ -5825,6 +5873,11 @@ export class SessionManager { const failureClass = status === 'failed' ? (decision.failureClass ?? 'app_restarted') : undefined; const abortSource = status === 'cancelled' ? (decision.abortSource ?? 'unknown') : undefined; + const candidateContextCompactionOutcome = + existingTerminal?.actions?.stateDelta?.contextCompactionOutcome; + const contextCompactionOutcome = isContextCompactionOutcome(candidateContextCompactionOutcome) + ? candidateContextCompactionOutcome + : undefined; const terminalEvent = existingTerminal ?? buildRecoveredTerminalRuntimeEvent({ @@ -5873,6 +5926,10 @@ export class SessionManager { ts, ...(failureClass ? { errorClass: failureClass } : {}), ...(abortSource ? { abortSource } : {}), + ...(inspected.header.rootExecutionKind + ? { rootExecutionKind: inspected.header.rootExecutionKind } + : {}), + ...(contextCompactionOutcome ? { contextCompactionOutcome } : {}), }, policy, ), @@ -5886,7 +5943,13 @@ export class SessionManager { run: AgentRunHeader, decision: AgentRunRecoveryDecision, status: TurnRecord['status'], - options: { ts: number; errorClass?: string; abortSource?: string }, + options: { + ts: number; + errorClass?: string; + abortSource?: string; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + contextCompactionOutcome?: ContextCompactionOutcome; + }, policy: RecoveryPolicy = { kind: 'best_effort' }, ): Promise { if (!isSessionInlineRun(run)) return; @@ -5896,7 +5959,7 @@ export class SessionManager { [] as StoredMessage[], ); const latest = latestTurnState(messages, decision.turnId); - if (latest && isTerminalTurnStatus(latest.status) && latest.status === status) return; + if (terminalTurnStateMatches(latest, status, options)) return; await this.appendTurnState(sessionId, decision.turnId, status, decision.lineage, options); } } @@ -6516,10 +6579,6 @@ function isTerminalRunStatus(status: AgentRunHeader['status']): boolean { return status === 'completed' || status === 'failed' || status === 'cancelled'; } -function isTerminalTurnStatus(status: TurnRecord['status']): boolean { - return status === 'completed' || status === 'failed' || status === 'aborted'; -} - function terminalTurnStatus(status: AgentRunRecoveryDecision['status']): TurnRecord['status'] { if (status === 'cancelled') return 'aborted'; return status; diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index 468005fc24..9c5fc73d12 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -18,7 +18,11 @@ */ import type { AgentRunHeader } from '@maka/core/agent-run'; -import { failureClassFromCompleteStopReason, type SessionEvent } from '@maka/core/events'; +import { + failureClassFromCompleteStopReason, + type ContextCompactionOutcome, + type SessionEvent, +} from '@maka/core/events'; import type { SessionBlockedReason, SessionHeader, @@ -47,6 +51,8 @@ export interface BuildTurnStateMessageInput { lineage?: TurnStateLineage; errorClass?: string; abortSource?: string; + rootExecutionKind?: AgentRunHeader['rootExecutionKind']; + contextCompactionOutcome?: ContextCompactionOutcome; partialOutputRetained: boolean; } @@ -80,6 +86,10 @@ export function buildTurnStateMessage(input: BuildTurnStateMessageInput): TurnSt ...(input.status === 'aborted' ? { abortedAt: input.ts } : {}), ...(input.status === 'aborted' && input.abortSource ? { abortSource: input.abortSource } : {}), ...(input.status === 'failed' ? { errorClass: input.errorClass ?? 'unknown' } : {}), + ...(input.rootExecutionKind ? { rootExecutionKind: input.rootExecutionKind } : {}), + ...(input.contextCompactionOutcome + ? { contextCompactionOutcome: input.contextCompactionOutcome } + : {}), partialOutputRetained: input.partialOutputRetained, }; } @@ -94,6 +104,37 @@ export function turnHasRetainedOutput(messages: readonly StoredMessage[], turnId ); } +export function terminalTurnStateMatches( + state: TurnStateMessage | undefined, + status: TurnRecord['status'], + semantic: Pick, +): boolean { + if (!state || !isTerminalTurnStatus(state.status) || state.status !== status) return false; + if ( + semantic.rootExecutionKind !== undefined && + state.rootExecutionKind !== semantic.rootExecutionKind + ) { + return false; + } + const expectedOutcome = semantic.contextCompactionOutcome; + if (expectedOutcome === undefined) return true; + const actualOutcome = state.contextCompactionOutcome; + if (expectedOutcome.kind === 'compacted') { + return ( + actualOutcome?.kind === 'compacted' && + actualOutcome.checkpointId === expectedOutcome.checkpointId + ); + } + if (expectedOutcome.kind === 'unchanged') { + return actualOutcome?.kind === 'unchanged' && actualOutcome.reason === expectedOutcome.reason; + } + return actualOutcome?.kind === 'failed' && actualOutcome.reason === expectedOutcome.reason; +} + +export function isTerminalTurnStatus(status: TurnRecord['status']): boolean { + return status === 'completed' || status === 'failed' || status === 'aborted'; +} + export function normalizeStopSessionSource( source: 'stop_button' | 'graph_supervisor' | undefined, ): string | undefined { diff --git a/packages/ui/src/__tests__/context-compaction-turn.test.tsx b/packages/ui/src/__tests__/context-compaction-turn.test.tsx new file mode 100644 index 0000000000..f555c18827 --- /dev/null +++ b/packages/ui/src/__tests__/context-compaction-turn.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { TurnView } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; +import { materializeTurns } from '../materialize.js'; + +test('renders a context-compaction-only Turn as one system row', () => { + const [turn] = materializeTurns( + [ + { + type: 'turn_state', + id: 'state-1', + turnId: 'turn-compact', + ts: 1, + status: 'completed', + rootExecutionKind: 'context_compact', + contextCompactionOutcome: { kind: 'compacted', checkpointId: 'checkpoint-1' }, + partialOutputRetained: false, + }, + ], + 'en', + ); + assert.ok(turn); + + const markup = renderToStaticMarkup( + + + , + ); + + assert.equal(markup.includes('maka-chat-system-message'), true); + assert.equal(markup.includes('Context compacted.'), true); + assert.equal(markup.split('Context compacted.').length - 1, 1); +}); diff --git a/packages/ui/src/__tests__/materialize.test.ts b/packages/ui/src/__tests__/materialize.test.ts index 197db51e71..046a758c46 100644 --- a/packages/ui/src/__tests__/materialize.test.ts +++ b/packages/ui/src/__tests__/materialize.test.ts @@ -193,6 +193,152 @@ describe("steering timeline", () => { }); describe("materializeChat message metadata", () => { + test("projects a context compaction turn as one localized system row", () => { + const running: StoredMessage[] = [ + { + type: "turn_state", + id: "compact-running", + turnId: "compact-turn", + ts: 1, + status: "running", + rootExecutionKind: "context_compact", + partialOutputRetained: false, + }, + ]; + const compacted: StoredMessage[] = [ + ...running, + { + type: "turn_state", + id: "compact-finished", + turnId: "compact-turn", + ts: 2, + status: "completed", + rootExecutionKind: "context_compact", + contextCompactionOutcome: { kind: "compacted", checkpointId: "checkpoint-1" }, + partialOutputRetained: false, + }, + ]; + + assert.equal(materializeTurns(running, "en")[0]?.notes[0]?.text, "Compacting context…"); + assert.equal(materializeTurns(compacted, "en")[0]?.notes[0]?.text, "Context compacted."); + assert.equal(materializeTurns(compacted, "zh")[0]?.notes[0]?.text, "上下文已压缩。"); + const copiedCompactedState: StoredMessage = { + type: "turn_state", + id: "compact-finished-copied", + turnId: "compact-turn", + ts: 2, + status: "completed", + rootExecutionKind: "context_compact", + contextCompactionOutcome: { kind: "compacted", checkpointId: null }, + partialOutputRetained: false, + }; + assert.equal( + materializeTurns([...running, copiedCompactedState], "en")[0]?.notes[0]?.text, + "Context compacted.", + ); + }); + + test("projects unchanged and failed context compaction outcomes", () => { + const turnState = ( + outcome: Extract["contextCompactionOutcome"], + ): StoredMessage => ({ + type: "turn_state", + id: `compact-${outcome?.kind}`, + turnId: `compact-${outcome?.kind}`, + ts: 1, + status: "completed", + rootExecutionKind: "context_compact", + contextCompactionOutcome: outcome, + partialOutputRetained: false, + }); + + assert.equal( + materializeTurns([turnState({ kind: "unchanged", reason: "already_compacted" })], "en")[0] + ?.notes[0]?.text, + "Nothing to compact.", + ); + assert.equal( + materializeTurns( + [ + turnState({ kind: "failed", reason: "summarizer_failed" }), + { + type: "system_note", + id: "legacy-failed-note", + turnId: "compact-failed", + ts: 2, + kind: "context_compaction_failed_open", + }, + ], + "en", + )[0]?.notes[0]?.text, + "Context compaction failed.", + ); + assert.equal( + materializeChat( + [ + turnState({ kind: "failed", reason: "summarizer_failed" }), + { + type: "system_note", + id: "legacy-failed-note", + turnId: "compact-failed", + ts: 2, + kind: "context_compaction_failed_open", + }, + ], + "en", + ).length, + 1, + ); + }); + + test("keeps context compaction rows in transcript order", () => { + const messages: StoredMessage[] = [ + { + type: "user", + id: "before", + turnId: "turn-before", + ts: 1, + text: "before", + }, + { + type: "turn_state", + id: "compact-running", + turnId: "compact-turn", + ts: 2, + status: "running", + rootExecutionKind: "context_compact", + partialOutputRetained: false, + }, + { + type: "turn_state", + id: "compact-aborted", + turnId: "compact-turn", + ts: 3, + status: "aborted", + rootExecutionKind: "context_compact", + partialOutputRetained: false, + }, + { + type: "assistant", + id: "after", + turnId: "turn-after", + ts: 4, + text: "after", + modelId: "test-model", + }, + ]; + + assert.deepEqual( + materializeChat(messages, "en").map(({ id, text }) => ({ id, text })), + [ + { id: "before", text: "before" }, + { id: "context-compaction:compact-turn", text: "Context compaction interrupted." }, + { id: "after", text: "after" }, + ], + ); + assert.equal(materializeTurns(messages, "zh")[1]?.notes[0]?.text, "上下文压缩已中断。"); + }); + test("localizes visible system notes", () => { const messages: StoredMessage[] = [ { diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 6be75b128b..b2a5062166 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -304,6 +304,13 @@ export interface ConversationCopy { removeQuoteAriaLabel: string; aborted: string; abortedByStop: string; + contextCompaction: { + running: string; + compacted: string; + unchanged: string; + aborted: string; + failed: string; + }; systemNotes: { contextCompacted: string; contextCompactionFailedOpen: string; @@ -499,6 +506,13 @@ const CONVERSATION_COPY = { you: '你', assistant: 'Maka', processing: '正在处理…', continuing: '继续中…', workingPhrases: ['正在琢磨…', '正在推敲…', '正在盘算…', '正在钻研…', '正在忙活…', '正在梳理…', '正在打磨…', '正在鼓捣…', '正在酝酿…', '正在攻坚…', '正在权衡…', '正在拾掇…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `${formatRetryDelay(seconds, 'zh')}后重试(${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `正在重试(${attempt}/${maxAttempts})`, providerRetryReason: { network: '网络中断', provider_capacity: '模型服务暂时满载', provider_unavailable: '模型服务暂时不可用', rate_limit: '触发模型速率限制', timeout: '请求超时', unknown: '模型请求失败' }, safeResumePending: '正在验证…', safeResume: '安全恢复', thinking: '深度思考', truncated: '已截断', copied: '已复制', copying: '复制中', copyFailed: '复制失败', copy: '复制', editMessage: '编辑并重发', editMessageDisabledRunning: '当前回答仍在进行中,结束后再编辑', editMessageDisabledAttachments: '包含附件的历史消息暂不支持编辑并重发', editMessageDisabledQuotes: '包含引用的历史消息暂不支持编辑并重发', editMessageDisabledTransformedText: '通过显式技能发送的历史消息暂不支持编辑并重发', userAriaLabel: '你发送的消息', systemAriaLabel: '系统消息', assistantAriaLabel: 'Maka 的回答', answerActionsAriaLabel: (context) => `回答操作${context ? `:${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action}回答${context ? `:${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action}消息${context ? `:${context}` : ''}`, sourceAriaLabel: '本轮回答的来源', derivativesAriaLabel: '本轮回答的衍生', scheduledTaskTriggered: '定时任务触发', scheduledTaskTitle: (id) => `由定时任务触发 · ${id}`, legacyAutomationTriggered: '旧版自动化(仅历史)', legacyAutomationTitle: (id) => `由旧版自动化触发 · ${id} · 仅保留历史,不会再次执行`, goalContinued: 'Goal 自动继续', goalTitle: (id) => `由 Goal 继续执行 · ${id}`, agentGraphTriggered: 'Agent Graph 自动继续', agentGraphTitle: (graphId) => `由 Agent Graph 调度器触发 · ${graphId}`, thinkingTruncatedTitle: '部分 reasoning 已截断;显示的是最近的内容', outputTruncatedTitle: '助手输出已超过单次回合上限,超出部分未渲染。如需完整内容请重新生成或查看持久化的任务日志。', removeAttachmentAriaLabel: (name) => `移除 ${name}`, quoteLabel: '引用', quoteExpandAriaLabel: '展开引用全文', quoteCollapseAriaLabel: '收起引用', removeQuoteAriaLabel: '移除引用', aborted: '(已中断)', abortedByStop: '(已中断 · 由停止按钮触发)', + contextCompaction: { + running: '正在压缩上下文…', + compacted: '上下文已压缩。', + unchanged: '无需压缩。', + aborted: '上下文压缩已中断。', + failed: '上下文压缩失败。', + }, systemNotes: { contextCompacted: '已压缩较早的对话内容,以适应模型上下文窗口。', contextCompactionFailedOpen: '上下文摘要失败;本轮已在未生成新摘要的情况下继续。', @@ -647,6 +661,13 @@ const CONVERSATION_COPY = { you: 'You', assistant: 'Maka', processing: 'Working…', continuing: 'Continuing…', workingPhrases: ['Pondering…', 'Tinkering…', 'Untangling…', 'Digging in…', 'Mulling…', 'Chewing on it…', 'Wrangling…', 'Piecing it together…'], providerRetryScheduled: (seconds, attempt, maxAttempts) => `Retrying in ${formatRetryDelay(seconds, 'en')} (${attempt}/${maxAttempts})`, providerRetryStarted: (attempt, maxAttempts) => `Retrying (${attempt}/${maxAttempts})`, providerRetryReason: { network: 'Network interrupted', provider_capacity: 'The model service is temporarily at capacity', provider_unavailable: 'Model service temporarily unavailable', rate_limit: 'Model rate limit reached', timeout: 'Request timed out', unknown: 'Model request failed' }, safeResumePending: 'Checking…', safeResume: 'Safe recovery', thinking: 'Thinking', truncated: 'Truncated', copied: 'Copied', copying: 'Copying', copyFailed: 'Copy failed', copy: 'Copy', editMessage: 'Edit & resend', editMessageDisabledRunning: 'Wait for this answer to finish before editing', editMessageDisabledAttachments: 'Edit & resend does not yet support messages with attachments', editMessageDisabledQuotes: 'Edit & resend does not yet support messages with quotes', editMessageDisabledTransformedText: 'Edit & resend does not yet support messages sent with an explicit skill', userAriaLabel: 'Your message', systemAriaLabel: 'System message', assistantAriaLabel: "Maka's response", answerActionsAriaLabel: (context) => `Response actions${context ? `: ${context}` : ''}`, answerActionAriaLabel: (action, context) => `${action} response${context ? `: ${context}` : ''}`, messageActionAriaLabel: (action, context) => `${action} message${context ? `: ${context}` : ''}`, sourceAriaLabel: 'Source of this response', derivativesAriaLabel: 'Responses derived from this one', scheduledTaskTriggered: 'Triggered by scheduled task', scheduledTaskTitle: (id) => `Triggered by scheduled task · ${id}`, legacyAutomationTriggered: 'Legacy Automation (history only)', legacyAutomationTitle: (id) => `Triggered by legacy Automation · ${id} · Historical only; it will not run again`, goalContinued: 'Continued by Goal', goalTitle: (id) => `Continued by Goal · ${id}`, agentGraphTriggered: 'Continued by Agent Graph', agentGraphTitle: (graphId) => `Triggered by the Agent Graph scheduler · ${graphId}`, thinkingTruncatedTitle: 'Some reasoning was truncated; showing the most recent content', outputTruncatedTitle: 'The assistant output exceeded the per-turn limit. Regenerate it or inspect the persisted task log for the complete content.', removeAttachmentAriaLabel: (name) => `Remove ${name}`, quoteLabel: 'Quote', quoteExpandAriaLabel: 'Show the full quoted excerpt', quoteCollapseAriaLabel: 'Collapse the quoted excerpt', removeQuoteAriaLabel: 'Remove quote', aborted: '(Interrupted)', abortedByStop: '(Interrupted · Stop button)', + contextCompaction: { + running: 'Compacting context…', + compacted: 'Context compacted.', + unchanged: 'Nothing to compact.', + aborted: 'Context compaction interrupted.', + failed: 'Context compaction failed.', + }, systemNotes: { contextCompacted: 'Context compacted to keep this session within the model window.', contextCompactionFailedOpen: 'Context summary failed; the session continued without a new summary.', diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts index 2f26a26174..1e3cab2e2c 100644 --- a/packages/ui/src/materialize.ts +++ b/packages/ui/src/materialize.ts @@ -150,12 +150,42 @@ function systemNoteLabel(kind: string, locale: UiLocale): string { return kind; } +function contextCompactionLabel(turn: TurnRecord, locale: UiLocale): string { + const copy = getConversationCopy(locale).messages.contextCompaction; + if (turn.status === "running") return copy.running; + if (turn.status === "aborted") return copy.aborted; + if (turn.contextCompactionOutcome?.kind === "compacted") return copy.compacted; + if (turn.contextCompactionOutcome?.kind === "unchanged") return copy.unchanged; + return copy.failed; +} + +function isLegacyCompactionNote(kind: string): boolean { + return kind === "context_compacted" || kind === "context_compaction_failed_open"; +} + export function materializeChat( messages: readonly StoredMessage[], locale: UiLocale = "en", ): ChatItem[] { const items: ChatItem[] = []; + const compactionTurns = new Map( + deriveTurnRecords(messages) + .filter((turn) => turn.rootExecutionKind === "context_compact") + .map((turn) => [turn.turnId, turn]), + ); + const emittedCompactionTurnIds = new Set(); for (const message of messages) { + const turnId = (message as { turnId?: string }).turnId; + const compactionTurn = turnId === undefined ? undefined : compactionTurns.get(turnId); + if (compactionTurn && !emittedCompactionTurnIds.has(compactionTurn.turnId)) { + emittedCompactionTurnIds.add(compactionTurn.turnId); + items.push({ + id: `context-compaction:${compactionTurn.turnId}`, + role: "system", + text: contextCompactionLabel(compactionTurn, locale), + ts: message.ts, + }); + } if (message.type === "user") { items.push({ id: message.id, @@ -183,7 +213,12 @@ export function materializeChat( }); if ( message.type === "system_note" && - VISIBLE_SYSTEM_NOTES.has(message.kind) + VISIBLE_SYSTEM_NOTES.has(message.kind) && + !( + message.turnId !== undefined && + compactionTurns.has(message.turnId) && + isLegacyCompactionNote(message.kind) + ) ) { items.push({ id: message.id, @@ -757,7 +792,9 @@ export function materializeTurns( } } else if ( message.type === "system_note" && - VISIBLE_SYSTEM_NOTES.has(message.kind) + VISIBLE_SYSTEM_NOTES.has(message.kind) && + !(turnRecordById.get(turnId)?.rootExecutionKind === "context_compact" && + isLegacyCompactionNote(message.kind)) ) { turn.notes.push({ id: message.id, @@ -784,6 +821,18 @@ export function materializeTurns( } } + for (const record of turnRecords) { + if (record.rootExecutionKind !== "context_compact") continue; + const turn = byId.get(record.turnId); + if (!turn) continue; + turn.notes.push({ + id: `context-compaction:${record.turnId}`, + role: "system", + text: contextCompactionLabel(record, locale), + ts: turn.startedAt, + }); + } + // Second pass: build the canonical tool map. Live tools are applied // separately by overlayLiveTurn so streaming deltas never force settled // history to rematerialize.