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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions apps/desktop/src/main/__tests__/streaming-handoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,4 +913,54 @@ describe('single live-turn handoff', () => {
]);
assert.equal(liveTurns.get()['session-1'], undefined);
});

it('retires a re-seeded compaction row when the refreshed transcript is terminal', () => {
const liveTurns = createStateSetter<Record<string, LiveTurnProjection>>({});
const ref = { current: liveTurns.get() };
const interactions = createStateSetter<InteractionQueues>({});
const handlers = createAppShellSessionEventHandlers({
uiLocale: 'en',
activeIdRef: { current: 'session-1' },
liveTurnBySessionRef: ref,
refreshMessages: async () => true,
refreshSessions: async () => [],
setLiveTurnBySession: (updater) => {
liveTurns.set(updater);
ref.current = liveTurns.get();
},
setInteractionBySession: interactions.set,
showModelSetupToast: () => {},
toastApi: { error: () => {} },
});

// A newly attached observer can only seed the still-running identity; it
// has no prior snapshot from which to synthesize the missed terminal event.
handlers.handleEvent('session-1', {
type: 'context_compaction_started',
id: 'compaction-started-1',
turnId: 'turn-compact',
ts: 1,
});
assert.equal(liveTurns.get()['session-1']?.rootExecutionKind, 'context_compact');

handlers.reconcilePersistedMessages('session-1', [
{
type: 'system_note',
id: 'compaction-settled-1',
turnId: 'turn-compact',
ts: 2,
kind: 'context_compacted',
},
{
type: 'turn_state',
id: 'turn-terminal-1',
turnId: 'turn-compact',
ts: 3,
status: 'completed',
partialOutputRetained: false,
},
]);

assert.equal(liveTurns.get()['session-1'], undefined);
});
});
46 changes: 46 additions & 0 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3233,3 +3233,49 @@ export const WorkbarCollapseKeepsOneToggleInPlace: Story = {
}
},
};


// Real path (#3587): an explicit compaction runs as its own host Turn. The
// transcript shows a live "正在压缩上下文…" row driven by the live Turn snapshot
// (rootExecutionKind: 'context_compact'), with no assistant content of its own.
export const ContextCompactionRunning: Story = {
render: () => (
<ComposedShell
session={{ status: 'running', streaming: true }}
chat={{
runningStatus: true,
messages: [
user('msg-c-1', 'turn-c1', 6, '继续把上下文压缩那个功能实现完。'),
assistant('msg-c-2', 'turn-c1', 5, '好的,我先梳理一下现有实现,再动手。'),
{ type: 'turn_state', id: 'state-c1', turnId: 'turn-c1', ts: NOW - 300_000, status: 'completed', partialOutputRetained: false },
{ type: 'turn_state', id: 'state-compact', turnId: 'turn-compact', ts: NOW - 2_000, status: 'running', partialOutputRetained: false },
],
liveTurn: {
turnId: 'turn-compact',
phase: 'waiting',
steps: [],
rootExecutionKind: 'context_compact',
startedAt: NOW - 2_000,
},
}}
/>
),
};

// Real path (#3587): the compaction Turn ends. The live row settles into the
// durable `context_compacted` system note, rendered in transcript order.
export const ContextCompactionCompacted: Story = {
render: () => (
<ComposedShell
chat={{
messages: [
user('msg-c-1', 'turn-c1', 6, '继续把上下文压缩那个功能实现完。'),
assistant('msg-c-2', 'turn-c1', 5, '好的,我先梳理一下现有实现,再动手。'),
{ type: 'turn_state', id: 'state-c1', turnId: 'turn-c1', ts: NOW - 300_000, status: 'completed', partialOutputRetained: false },
{ type: 'system_note', id: 'note-compact', turnId: 'turn-compact', ts: NOW - 1_000, kind: 'context_compacted' },
{ type: 'turn_state', id: 'state-compact', turnId: 'turn-compact', ts: NOW - 1_000, status: 'completed', partialOutputRetained: false },
],
}}
/>
),
};
1 change: 1 addition & 0 deletions packages/core/src/backend-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export type BackendSessionEvent = Exclude<
| 'message_admission'
| 'client_capability_request'
| 'client_capability_decision_ack'
| 'context_compaction_started'
| 'permission_request'
| 'permission_answer_ack'
| 'permission_closure_ack'
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,8 @@ export type SessionEvent =
| ProviderRetryEvent
| ErrorEvent
| CompleteEvent
| AbortEvent;
| AbortEvent
| ContextCompactionStartedEvent;

export interface TextDeltaEvent extends BaseEvent {
type: 'text_delta';
Expand Down Expand Up @@ -1296,6 +1297,16 @@ export interface AbortEvent extends BaseEvent {
reason: 'user_stop' | 'redirect' | 'timeout' | 'crash';
}

/**
* A host-owned explicit context-compaction Turn has started. Synthesized by the
* Runtime Host session projector (not the kernel) purely so a client can render
* a "compacting" transcript row while the Turn is in flight; it carries no
* durable state and is excluded from `BackendSessionEvent` like `queue_update`.
*/
export interface ContextCompactionStartedEvent extends BaseEvent {
type: 'context_compaction_started';
}

// ============================================================================
// UI → Backend commands
// ============================================================================
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@ describe('Runtime Host bootstrap protocol', () => {
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 117);
});

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

test('selects the highest mutually supported protocol and rejects a gap', () => {
assert.equal(negotiateProtocol({ min: 0, max: 0 }, { min: 0, max: 0 }), 0);
assert.equal(negotiateProtocol({ min: 1, max: 3 }, { min: 2, max: 4 }), 3);
Expand Down
125 changes: 125 additions & 0 deletions packages/runtime-host/src/__tests__/session-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,3 +912,128 @@ test('live tool_start keeps intent and argsPreview, and never fabricates args',
assert.deepEqual(event.argsPreview, { command: 'git status --porcelain' });
assert.equal(event.args, undefined);
});

test('seeds a context-compaction-started event for a running compaction Turn', () => {
const projector = new RuntimeHostSessionProjector(
snapshot({
rootTurn: {
sessionId: 'session-1',
turnId: 'turn-compact',
runId: 'run-compact',
status: 'running',
rootExecutionKind: 'context_compact',
},
}),
createRuntimeHostSessionProjectionSeed([], snapshot()),
() => 10,
);
const seeded = projector.seedActive(true);
assert.equal(seeded.length, 1);
assert.equal(seeded[0]?.type, 'context_compaction_started');
assert.equal(seeded[0]?.turnId, 'turn-compact');
});

test('emits a context-compaction-started event when a compaction Turn starts', () => {
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-compact',
runId: 'run-compact',
status: 'running',
rootExecutionKind: 'context_compact',
},
}),
}).events;
assert.ok(
events.some(
(event) => event.type === 'context_compaction_started' && event.turnId === 'turn-compact',
),
);
});

test('emits context-compaction-started on the admitted → running transition at one runId', () => {
// The real lifecycle keeps the same runId: `admitted` (no rootExecutionKind)
// then `running` / context_compact. Gating on a runId change would miss this
// and only surface the row on reconnect.
const projector = new RuntimeHostSessionProjector(
snapshot({
rootTurn: {
sessionId: 'session-1',
turnId: 'turn-compact',
runId: 'run-compact',
status: 'admitted',
},
}),
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-compact',
runId: 'run-compact',
status: 'running',
rootExecutionKind: 'context_compact',
},
}),
}).events;
assert.equal(events.filter((event) => event.type === 'context_compaction_started').length, 1);
});

test('projects the typed context-compaction outcome onto the completed Turn event', () => {
const projector = new RuntimeHostSessionProjector(
snapshot({
rootTurn: {
sessionId: 'session-1',
turnId: 'turn-compact',
runId: 'run-compact',
status: 'running',
rootExecutionKind: 'context_compact',
},
}),
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-compact',
runId: 'run-compact',
status: 'completed',
terminalEventId: 'terminal-1',
contextCompactionOutcome: { kind: 'compacted', checkpointId: 'checkpoint-1' },
},
}),
}).events;
const complete = events.find((event) => event.type === 'complete');
assert.ok(complete);
assert.deepEqual(
complete && 'contextCompactionOutcome' in complete
? complete.contextCompactionOutcome
: undefined,
{ kind: 'compacted', checkpointId: 'checkpoint-1' },
);
});
47 changes: 46 additions & 1 deletion packages/runtime-host/src/adapter/session-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
*/

import { isDeepStrictEqual } from 'node:util';
import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events';
import type {
ActiveInteractionRequestEvent,
ContextCompactionStartedEvent,
SessionEvent,
} from '@maka/core/events';
import type { StoredMessage, TurnRecord } from '@maka/core/session';
import type {
InteractionPendingSnapshot,
Expand Down Expand Up @@ -172,6 +176,11 @@ export class RuntimeHostSessionProjector {
);
}
if (isRuntimeHostTerminalTurn(root)) return events;
// Re-derive the running compaction row on reconnect / restart: the Host keeps
// the compaction Turn alive, so a reconnecting client learns of it here.
if (root.rootExecutionKind === 'context_compact') {
events.push(contextCompactionStartedEvent(root, this.#now()));
}
let seededAssistantText = false;
if (includeAssistantText) {
for (const accumulator of this.#accumulators.values()) {
Expand Down Expand Up @@ -437,6 +446,20 @@ export class RuntimeHostSessionProjector {
events.push(projectQueueUpdate(next.queue, root.turnId, this.#now()));
}
if (startedTurn) this.#accumulators.clear();
// Emit the presentation-only compaction-started event when the root Turn
// FIRST becomes a `context_compact` run, not only when the runId changes.
// The real lifecycle is `admitted (no rootExecutionKind) → running/
// context_compact` at the SAME runId, so gating on startedTurn would miss
// the live transition and only surface the row on reconnect via seedActive.
const rootIsCompaction =
!!root && !isRuntimeHostTerminalTurn(root) && root.rootExecutionKind === 'context_compact';
const previousWasCompaction =
!!previousRoot &&
!isRuntimeHostTerminalTurn(previousRoot) &&
previousRoot.rootExecutionKind === 'context_compact';
if (root && rootIsCompaction && !previousWasCompaction) {
events.push(contextCompactionStartedEvent(root, this.#now()));
}
const retry = liveProviderRetryEvent(previousRoot, root, this.#now());
if (retry) events.push(retry);
const terminalTurn =
Expand Down Expand Up @@ -473,6 +496,10 @@ export class RuntimeHostSessionProjector {
turnId: root.turnId,
ts: this.#now(),
stopReason: 'end_turn',
// Forward the typed compaction outcome already carried by the canonical
// Turn snapshot so the renderer can settle the running toast and show the
// terminal state. This projects an existing snapshot field (no turn-state
// persistence), so checkpointId stays a string.
Comment thread
liuxiaocs7 marked this conversation as resolved.
...(root.contextCompactionOutcome
? { contextCompactionOutcome: root.contextCompactionOutcome }
: {}),
Expand Down Expand Up @@ -541,6 +568,24 @@ function projectMessageRetractionEvents(
}));
}

/**
* Presentation-only event that drives the renderer's live "compacting" row.
* Emitted on both the live transition (`accept`) and reconnect (`seedActive`)
* with a deterministic id keyed on the run, so a reconnect re-emits it
* idempotently.
*/
function contextCompactionStartedEvent(
turn: { runId: string; turnId: string },
now: number,
): ContextCompactionStartedEvent {
return {
type: 'context_compaction_started',
id: `host-compaction-started:${turn.runId}`,
turnId: turn.turnId,
ts: now,
};
}

export function projectRuntimeHostInteractionRequest(
interaction: InteractionPendingSnapshot,
now: number,
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ 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 = 124 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 125 as const;
// 125: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'`
// so a running context-compaction Turn can render a transcript row. Epoch-124
// peers reject the added optional field on the strict live snapshot shape.
// 124: PTY delivery is independent of the ordered Session state stream. A
// bounded PTY overflow requests terminal-only snapshot recovery.
// 123: Failed turns carry canonical retry decisions through bounded projections.
Expand Down
Loading