Skip to content

Commit a6866d0

Browse files
authored
fix(runtime-host): forward the durable steering echo to session subscribers (#3316)
* fix(runtime-host): forward the durable steering echo to session subscribers The TUI never rendered a consumed steering message: the host event pump dropped steering_message (not a transient tool/text event), leaving the transient in-flight queue observation as the only live render path, which the coalesced canonical refresh can skip entirely (queued -> consumed). Forward the durable steering_message on the session-event frame so every subscriber renders the interjection in place, keep the queue in-flight synthesis for the attach/rejoin window, and dedup both paths per message. Bumps the session continuity wire schema to 5. Fixes #3304 Generated-by: Maka * refactor(runtime-host): name the forwarding rule, not the durability class isRuntimeSessionForwardedEvent / RuntimeSessionForwardedEvent describe what the predicate decides — forward live to subscribers — now that the durable steering_message belongs to it. Rename the client projectSessionEvent to match its Host-side counterpart. Addresses review on #3316. Generated-by: Maka * fix(runtime-host): suppress the steering echo when the bootstrap already rendered it subscription.open can bootstrap the durable steering message and install the subscriber before the Host's forwarded echo arrives; seed the render-dedup set from steering messages already durable in the transcript so the bootstrapped render stays the only one. Also rebased onto main and updated the new e2e test to the post-#3277 single-argument connectClient. Addresses review on #3316 Generated-by: Maka * test(runtime-host): restore steering queue fixture helper after rebase Generated-by: maka * fix(runtime-host): advance compatibility epoch for the continuity schema bump Generated-by: maka * fix(runtime-host): align steering types after rebase Generated-by: maka * test(runtime-host): use current turn start request Generated-by: maka
1 parent 8e9369b commit a6866d0

9 files changed

Lines changed: 421 additions & 27 deletions

packages/runtime-host/src/__tests__/execution-host-message.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,66 @@ import {
114114
withTimeout,
115115
} from './fixtures/execution-host-suite.js';
116116

117+
test('subscribed Clients receive the durable steering echo as a session event', async () => {
118+
await withExecutionRoot(async (fixture) => {
119+
const host = await fixture.startHost();
120+
const client = await connectClient(fixture.root);
121+
const subscription = await client.openSessionSubscription({
122+
sessionId: fixture.sessionId,
123+
transcript: { kind: 'none' },
124+
});
125+
const probe = new SubscriptionProbe(subscription);
126+
127+
const turnId = randomUUID();
128+
requireStartedTurn(
129+
await client.request('turn.start', {
130+
sessionId: fixture.sessionId,
131+
turnId,
132+
content: { text: FAKE_WAIT_FOR_STEERING_PROMPT },
133+
}),
134+
);
135+
const steeringId = randomUUID();
136+
const steeringContent = {
137+
text: '<steer>steer mid-turn</steer>',
138+
displayText: 'steer mid-turn',
139+
};
140+
const submitted = await client.request('turn.message.submit', {
141+
originHostEpoch: host.hostEpoch,
142+
sessionId: fixture.sessionId,
143+
messageId: steeringId,
144+
content: steeringContent,
145+
placement: 'current_turn',
146+
});
147+
assert.equal(submitted.disposition, 'steering');
148+
149+
// apache/maka#3304: the steering render must not depend on observing the
150+
// transient in-flight queue state; the durable echo is forwarded verbatim.
151+
const echoed = await probe.waitFor(
152+
(frame) =>
153+
frame.kind === 'subscription.session_event' && frame.event.type === 'steering_message',
154+
'continuity did not forward the durable steering echo',
155+
);
156+
assert.equal(echoed.kind, 'subscription.session_event');
157+
if (echoed.kind === 'subscription.session_event') {
158+
assert.equal(echoed.event.type, 'steering_message');
159+
if (echoed.event.type === 'steering_message') {
160+
assert.equal(echoed.event.turnId, turnId);
161+
assert.equal(echoed.event.messageId, steeringId);
162+
assert.deepEqual(echoed.event.content, steeringContent);
163+
}
164+
}
165+
166+
assert.equal(
167+
(await waitForTerminalTurn(client, fixture.sessionId, turnId)).status,
168+
'completed',
169+
);
170+
await subscription.close();
171+
await probe.done;
172+
await client.close();
173+
await fixture.stopHost(host);
174+
});
175+
});
176+
117177
test('steering becomes durable and ordered followups automatically start the next root', async () => {
118178
await withExecutionRoot(async (fixture) => {
119179
const host = await fixture.startHost();

packages/runtime-host/src/__tests__/protocol.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ describe('Runtime Host bootstrap protocol', () => {
250250
});
251251

252252
test('keeps the subscription queue Epoch correlated', () => {
253-
assert.equal(SESSION_CONTINUITY_SCHEMA_VERSION, 4);
253+
assert.equal(SESSION_CONTINUITY_SCHEMA_VERSION, 5);
254254
const opened = {
255255
requestId: 'open-1',
256256
operation: 'subscription.open',
@@ -467,6 +467,37 @@ describe('Runtime Host bootstrap protocol', () => {
467467
]) {
468468
assert.throws(() => decodeHostFrame({ ...envelope, event }), isInvalidFrame);
469469
}
470+
471+
// The durable steering echo shares the session-event frame without a
472+
// toolUseId; unknown keys stay rejected.
473+
const steering = {
474+
type: 'steering_message' as const,
475+
id: 'steering-event-1',
476+
turnId: 'turn-1',
477+
ts: 7,
478+
messageId: 'steering-message-1',
479+
content: { text: 'steer the turn' },
480+
};
481+
const decodedSteering = decodeHostFrame({ ...envelope, event: steering });
482+
assert.ok('kind' in decodedSteering);
483+
if ('kind' in decodedSteering) {
484+
assert.equal(decodedSteering.kind, 'subscription.session_event');
485+
if (decodedSteering.kind === 'subscription.session_event') {
486+
assert.deepEqual(decodedSteering.event, steering);
487+
}
488+
}
489+
assert.throws(
490+
() => decodeHostFrame({ ...envelope, event: { ...steering, toolUseId: 'tool-1' } }),
491+
isInvalidFrame,
492+
);
493+
assert.throws(
494+
() =>
495+
decodeHostFrame({
496+
...envelope,
497+
event: { ...steering, content: { text: 'x'.repeat(49 * 1024) } },
498+
}),
499+
isInvalidFrame,
500+
);
470501
assert.throws(
471502
() =>
472503
decodeHostFrame({

packages/runtime-host/src/__tests__/session-continuity-coordinator.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,39 @@ test('open is an inactive publication barrier and live sequence starts at nextSe
8888
coordinator.close();
8989
});
9090

91+
test('forwards the durable steering echo to subscribers as a session event', async () => {
92+
const sink = new RecordingSink();
93+
const coordinator = new SessionContinuityCoordinator(
94+
HOST_EPOCH,
95+
async () => canonical(),
96+
new SessionAdmissionGate(),
97+
);
98+
const connection = coordinator.attachConnection('connection-1', sink);
99+
const opened = await open(coordinator, 'connection-1');
100+
connection.activate(opened.subscriptionId);
101+
await delayImmediate();
102+
sink.frames.length = 0;
103+
104+
const steering = {
105+
type: 'steering_message' as const,
106+
id: 'steering-event-1',
107+
turnId: 'turn-1',
108+
ts: 7,
109+
messageId: 'steering-message-1',
110+
content: { text: 'steer the turn' },
111+
};
112+
await coordinator.acceptRuntimeEvent(SESSION_ID, 'run-1', steering);
113+
114+
assert.equal(sink.frames.length, 1);
115+
const frame = sink.frames[0];
116+
assert.equal(frame?.kind, 'subscription.session_event');
117+
if (frame?.kind !== 'subscription.session_event') return;
118+
assert.deepEqual(frame.event, steering);
119+
120+
connection.abort(opened.subscriptionId);
121+
coordinator.close();
122+
});
123+
91124
test('open snapshot includes pending Interactions from the canonical projection', async () => {
92125
const pending = pendingInteraction();
93126
const coordinator = new SessionContinuityCoordinator(

packages/runtime-host/src/__tests__/session-projector.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import assert from 'node:assert/strict';
2121
import test from 'node:test';
2222
import type { SessionEvent } from '@maka/core/events';
2323
import type { StoredMessage } from '@maka/core/session';
24+
import type { SteeringMessageSnapshot } from '../protocol/message.js';
2425
import {
2526
createRuntimeHostSessionProjectionSeed,
2627
RuntimeHostSessionProjector,
@@ -499,6 +500,162 @@ test('preserves the bounded shell-run correlation on a tool start', () => {
499500
);
500501
});
501502

503+
test('projects the durable steering echo even when the in-flight queue state was never observed', () => {
504+
// Regression for apache/maka#3304: the coalesced canonical refresh can jump
505+
// the queue straight from queued to consumed, so the in-flight synthesis
506+
// never fires. The forwarded steering_message event must render the message.
507+
const projector = new RuntimeHostSessionProjector(
508+
snapshot({ queue: queue(2, [steeringEntry('queued')]) }),
509+
createRuntimeHostSessionProjectionSeed([], snapshot()),
510+
() => 10,
511+
);
512+
513+
const skipped = projector.accept({
514+
kind: 'subscription.session_projection',
515+
hostEpoch: 'host-1',
516+
subscriptionId: 'subscription-1',
517+
sequence: 1,
518+
snapshot: snapshot({ queue: queue(4, []) }),
519+
});
520+
assert.deepEqual(
521+
skipped.events.map((event) => event.type),
522+
['queue_update'],
523+
);
524+
525+
const echoed = projector.accept(steeringFrame(2)).events;
526+
assert.equal(echoed.length, 1);
527+
assert.deepEqual(echoed[0], {
528+
type: 'steering_message',
529+
id: 'steering-event-1',
530+
turnId: 'turn-1',
531+
ts: 10,
532+
messageId: 'steering-message-1',
533+
content: { text: 'steer the turn' },
534+
});
535+
});
536+
537+
test('projects a steering message exactly once across both authoritative paths', () => {
538+
// The queue in-flight synthesis and the durable session-event echo race;
539+
// whichever projects the message first suppresses the other.
540+
const inFlightFirst = new RuntimeHostSessionProjector(
541+
snapshot({ queue: queue(2, [steeringEntry('queued')]) }),
542+
createRuntimeHostSessionProjectionSeed([], snapshot()),
543+
() => 10,
544+
);
545+
const synthesized = inFlightFirst.accept({
546+
kind: 'subscription.session_projection',
547+
hostEpoch: 'host-1',
548+
subscriptionId: 'subscription-1',
549+
sequence: 1,
550+
snapshot: snapshot({ queue: queue(3, [steeringEntry('in_flight')]) }),
551+
});
552+
assert.deepEqual(
553+
synthesized.events.map((event) => event.type),
554+
['steering_message', 'queue_update'],
555+
);
556+
assert.deepEqual(inFlightFirst.accept(steeringFrame(2)).events, []);
557+
558+
const echoFirst = new RuntimeHostSessionProjector(
559+
snapshot({ queue: queue(2, [steeringEntry('queued')]) }),
560+
createRuntimeHostSessionProjectionSeed([], snapshot()),
561+
() => 10,
562+
);
563+
assert.equal(echoFirst.accept(steeringFrame(1)).events.length, 1);
564+
const suppressed = echoFirst.accept({
565+
kind: 'subscription.session_projection',
566+
hostEpoch: 'host-1',
567+
subscriptionId: 'subscription-1',
568+
sequence: 2,
569+
snapshot: snapshot({ queue: queue(3, [steeringEntry('in_flight')]) }),
570+
});
571+
assert.deepEqual(
572+
suppressed.events.map((event) => event.type),
573+
['queue_update'],
574+
);
575+
});
576+
577+
test('seeds an unrendered in-flight steering message once on rejoin', () => {
578+
const projector = new RuntimeHostSessionProjector(
579+
snapshot({ queue: queue(3, [steeringEntry('in_flight')]) }),
580+
createRuntimeHostSessionProjectionSeed([], snapshot()),
581+
() => 10,
582+
);
583+
assert.deepEqual(
584+
projector.seedActive(false).map((event) => event.type),
585+
['steering_message', 'queue_update'],
586+
);
587+
// A live echo of the same message arriving after the seed is the duplicate.
588+
assert.deepEqual(projector.accept(steeringFrame(1)).events, []);
589+
});
590+
591+
test('suppresses the live echo for a steering message already durable in the bootstrap', () => {
592+
// subscription.open can bootstrap the durable steering message and install
593+
// the subscriber while the Host's forwarded echo for it is still pending:
594+
// the bootstrapped render must stay the only one (apache/maka#3316 review).
595+
const inFlight = snapshot({ queue: queue(3, [steeringEntry('in_flight')]) });
596+
const projector = new RuntimeHostSessionProjector(
597+
inFlight,
598+
createRuntimeHostSessionProjectionSeed(
599+
[userSteering('steering-message-1', 'steering-event-1')],
600+
inFlight,
601+
),
602+
() => 10,
603+
);
604+
605+
// Durable and in-flight: no synthesis seed…
606+
assert.deepEqual(
607+
projector.seedActive(false).map((event) => event.type),
608+
['queue_update'],
609+
);
610+
// …and the late echo of the same message is the duplicate.
611+
assert.deepEqual(projector.accept(steeringFrame(1)).events, []);
612+
// A different steering message still renders normally.
613+
assert.equal(projector.accept(steeringFrame(2, 'steering-message-2')).events.length, 1);
614+
});
615+
616+
function steeringEntry(state: 'queued' | 'in_flight'): SteeringMessageSnapshot {
617+
return {
618+
entryId: 'entry-1',
619+
messageId: 'steering-message-1',
620+
content: { text: 'steer the turn' },
621+
placement: 'current_turn',
622+
state,
623+
};
624+
}
625+
626+
function steeringFrame(sequence: number, messageId = 'steering-message-1'): SubscriptionFrame {
627+
return {
628+
kind: 'subscription.session_event',
629+
hostEpoch: 'host-1',
630+
subscriptionId: 'subscription-1',
631+
sequence,
632+
sessionId: 'session-1',
633+
runId: 'run-1',
634+
event: {
635+
type: 'steering_message',
636+
id: 'steering-event-1',
637+
turnId: 'turn-1',
638+
ts: 10,
639+
messageId,
640+
content: { text: 'steer the turn' },
641+
},
642+
};
643+
}
644+
645+
function userSteering(
646+
id: string,
647+
steeringEventId: string,
648+
): Extract<StoredMessage, { type: 'user' }> {
649+
return {
650+
type: 'user',
651+
id,
652+
turnId: 'turn-1',
653+
ts: 1,
654+
text: 'steer the turn',
655+
steeringEventId,
656+
};
657+
}
658+
502659
function deltaFrame(
503660
sequence: number,
504661
startOffset: number,
@@ -523,6 +680,13 @@ function deltaFrame(
523680
};
524681
}
525682

683+
function queue(
684+
queueRevision: number,
685+
steering: readonly SteeringMessageSnapshot[],
686+
): SessionContinuitySnapshot['queue'] {
687+
return { hostEpoch: 'host-1', queueRevision, steering, followup: [] };
688+
}
689+
526690
function snapshot(overrides: Partial<SessionContinuitySnapshot> = {}): SessionContinuitySnapshot {
527691
return {
528692
schemaVersion: SESSION_CONTINUITY_SCHEMA_VERSION,

0 commit comments

Comments
 (0)