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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,51 @@ export const RunningStatusDuringToolRun: 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 },
],
}}
/>
),
};

// Real path: Desktop Computer Use is exposed through the Runtime Host Client
// Capability bridge. The settled observation establishes the confirmed target;
// the following sequence inherits it while live progress replaces the generic
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/backend-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export type BackendSessionEvent = Exclude<
type:
| 'queue_update'
| 'message_admission'
| '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 @@ -510,7 +510,8 @@ export type SessionEvent =
| ProviderRetryEvent
| ErrorEvent
| CompleteEvent
| AbortEvent;
| AbortEvent
| ContextCompactionStartedEvent;

export interface TextDeltaEvent extends BaseEvent {
type: 'text_delta';
Expand Down Expand Up @@ -1224,6 +1225,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 @@ -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 > 50);
});

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
90 changes: 90 additions & 0 deletions packages/runtime-host/src/__tests__/session-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,93 @@ function assistant(id: string, text: string): Extract<StoredMessage, { type: 'as
modelId: 'gpt-5',
};
}

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('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' },
);
});
43 changes: 42 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 @@ -167,6 +171,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 @@ -410,6 +419,13 @@ export class RuntimeHostSessionProjector {
const startedTurn =
root && (!previousRoot || root.runId !== previousRoot.runId) ? root : undefined;
if (startedTurn) this.#accumulators.clear();
if (
startedTurn &&
!isRuntimeHostTerminalTurn(startedTurn) &&
startedTurn.rootExecutionKind === 'context_compact'
) {
events.push(contextCompactionStartedEvent(startedTurn, this.#now()));
}
const retry = liveProviderRetryEvent(previousRoot, root, this.#now());
if (retry) events.push(retry);
const terminalTurn =
Expand Down Expand Up @@ -446,6 +462,13 @@ 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.
...(root.contextCompactionOutcome
? { contextCompactionOutcome: root.contextCompactionOutcome }
: {}),
});
} else if (root.status === 'failed') {
events.push({
Expand Down Expand Up @@ -511,6 +534,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 @@ -92,7 +92,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 = 50 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 51 as const;
// 51: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'`
// so a running context-compaction Turn can render a transcript row. Epoch-50
// peers reject the added optional field on the strict live snapshot shape.
// 50: WorkHub can append durable coordination summaries and admit tool-free
// answers through its reserved Coordination Session authority.
// 49: WorkHub resolves one durable Coordination Session per Runtime Host.
Expand Down
20 changes: 19 additions & 1 deletion packages/runtime-host/src/protocol/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ export type TurnProviderRetry =
export type LiveTurnSnapshot = TurnSnapshotBase & {
status: Exclude<TurnRunStatus, 'completed' | 'failed' | 'cancelled'>;
providerRetry?: TurnProviderRetry;
/**
* Set when this live Turn is a host-owned explicit context-compaction run, so
* the renderer can show a "compacting" transcript row while it is in flight.
* Sourced from `AgentRunHeader.rootExecutionKind`; a `context_compact` Turn
* emits no assistant text, and this survives a Desktop reconnect because the
* Host re-projects the live snapshot.
*/
rootExecutionKind?: 'context_compact';
};

export type TurnSnapshot =
Expand Down Expand Up @@ -627,6 +635,13 @@ function requirePositiveCount(value: unknown, label: string): number {
return count;
}

function requireContextCompactRootExecutionKind(value: unknown): 'context_compact' {
if (value !== 'context_compact') {
throw invalidProtocolFrame('Invalid Turn rootExecutionKind');
}
return value;
}

export function decodeTurnSnapshot(value: unknown): TurnSnapshot {
const record = requireRecord(value, 'Turn snapshot');
const base = {
Expand Down Expand Up @@ -699,14 +714,17 @@ export function decodeTurnSnapshot(value: unknown): TurnSnapshot {
record,
'non-terminal Turn snapshot',
['sessionId', 'turnId', 'runId', 'status'],
['providerRetry'],
['providerRetry', 'rootExecutionKind'],
);
return {
...base,
status,
...(record.providerRetry !== undefined
? { providerRetry: decodeTurnProviderRetry(record.providerRetry) }
: {}),
...(record.rootExecutionKind !== undefined
? { rootExecutionKind: requireContextCompactRootExecutionKind(record.rootExecutionKind) }
: {}),
};
}

Expand Down
10 changes: 9 additions & 1 deletion packages/runtime-host/src/server/canonical-turn-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,15 @@ export async function readCanonicalTurnSnapshot(
if (run.status !== 'created' && !runEvents.some((event) => event.type === 'run_started')) {
throw new Error('Non-created Run has no durable start fact');
}
return { sessionId, turnId, runId, status: run.status };
return {
sessionId,
turnId,
runId,
status: run.status,
...(run.rootExecutionKind === 'context_compact'
? { rootExecutionKind: run.rootExecutionKind }
: {}),
};
}

function readContextCompactionOutcome(value: unknown): ContextCompactionOutcome | undefined {
Expand Down
Loading