Skip to content

Commit cfa0cce

Browse files
committed
fix(engine,client-core): unify automation echo identity and re-read when a leaf appears
1 parent 96e06ec commit cfa0cce

9 files changed

Lines changed: 88 additions & 35 deletions

File tree

packages/client/core/src/client/event-buffer.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ export interface SequencedAgentEvent {
2424
/** Client receive time (ms epoch), stamped when the event is ingested from the live stream.
2525
* Drives relative timestamps in the UI; absent for events replayed from a history read. */
2626
receivedAt?: number;
27-
/** Daemon-minted `(epoch, seq)` position — the projection merge cut; absent from unstamped
28-
* hosts (≤v79 daemons, the dev mock). */
27+
/** Daemon-minted `(epoch, seq)` position — the projection merge cut; absent from ≤v79 hosts. */
2928
position?: ConversationWatermark;
3029
runId?: RunId;
3130
/** The turn the daemon attributed the event to. A live user echo carries none: it is broadcast

packages/client/core/src/conversation-store.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export function createConversationStore(
6060
if (seed !== undefined && 'items' in seed) {
6161
return createProjectionStore(client, sessionId, seed, options.onResync ?? noop);
6262
}
63-
return createHistoryStore(client, sessionId, seed);
63+
return createHistoryStore(client, sessionId, seed, options.onResync ?? noop);
6464
}
6565

6666
/** Kinds the projection merge never drops on the watermark: their authoritative state lives in
@@ -290,6 +290,7 @@ function createHistoryStore(
290290
client: LinkCodeClient,
291291
sessionId: SessionId,
292292
seed: ConversationSeed | undefined,
293+
onResync: (reason: ConversationResyncReason) => void,
293294
): ConversationStore {
294295
const builder = createConversationBuilder();
295296
const uptoSeq = seed?.uptoSeq ?? 0;
@@ -328,6 +329,19 @@ function createHistoryStore(
328329
let seeded = false;
329330
/** Highest receive seq already examined (not necessarily folded — covered ones may be cut). */
330331
let consumedSeq = 0;
332+
let resyncRequested = false;
333+
334+
const requestResync = (): void => {
335+
if (resyncRequested) return;
336+
resyncRequested = true;
337+
queueMicrotask(() => onResync('graph'));
338+
};
339+
340+
const noteGraph = (change: ConversationGraphChange | undefined): void => {
341+
// A leaf appearing after an empty-graph / live-only read is the cutover: the owner must
342+
// re-read so the next store is a projection. History-path sessions never see this.
343+
if (change?.activeLeafTurnId !== undefined) requestResync();
344+
};
331345

332346
const sync = (): void => {
333347
if (!seeded) {
@@ -355,7 +369,17 @@ function createHistoryStore(
355369
};
356370

357371
return {
358-
subscribe: (onStoreChange) => client.subscribe(sessionId, onStoreChange),
372+
subscribe(onStoreChange) {
373+
noteGraph(client.latestGraphChange(sessionId));
374+
const unsubscribeEvents = client.subscribe(sessionId, onStoreChange);
375+
const unsubscribeGraph = client.subscribeGraphChanges(sessionId, (change) => {
376+
noteGraph(change);
377+
});
378+
return () => {
379+
unsubscribeEvents();
380+
unsubscribeGraph();
381+
};
382+
},
359383
getSnapshot() {
360384
sync();
361385
return builder.snapshot();

packages/client/core/tests/integration/conversation-client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('LinkCodeClient conversation graph API', () => {
6666
{ seq: 1, position: { epoch: 4, seq: 7 }, runId: 'run-1', turnId: 'turn-1' },
6767
{ seq: 2 },
6868
]);
69-
// An unstamped frame (≤v79 host, the dev mock) carries no position at all.
69+
// An unstamped frame (≤v79 host) carries no position at all.
7070
expect(client.eventsSnapshot(sessionId)[1]).not.toHaveProperty('position');
7171
expect(seen.map((entry) => entry.position)).toEqual([{ epoch: 4, seq: 7 }, undefined]);
7272

packages/client/core/tests/integration/conversation-store-projection.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,18 @@ describe('projection conversation store', () => {
197197
h.close();
198198
});
199199

200+
it('asks a live-only store to re-read when a leaf appears', async () => {
201+
const h = await harness();
202+
const store = createConversationStore(h.client, sessionId, undefined, {
203+
onResync: (reason) => h.resyncs.push(reason),
204+
});
205+
store.subscribe(noop);
206+
h.graphChanged(1, turn(1));
207+
await tick();
208+
expect(h.resyncs).toEqual(['graph']);
209+
h.close();
210+
});
211+
200212
it('treats a graph move onto a leaf whose row arrived live as a plain continuation', async () => {
201213
const h = await harness();
202214
const store = h.store(seedOf([userRow(1, 'first')], { epoch: 1, seq: 1 }, 3));

packages/client/workbench/src/mock/dev-mock-host.ts

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,11 +1274,6 @@ export class DevMockHost {
12741274
case 'shell-command': {
12751275
const content = [textBlock(`$ ${input.command}`)];
12761276
const turn = this.beginTurn(session, content, input);
1277-
this.emit(sessionId, {
1278-
type: 'user-message',
1279-
messageId: userRowMessageId(turn.graph.turnId),
1280-
content,
1281-
});
12821277
settleTurn(session, turn, 'completed');
12831278
this.sendSuccess(replyTo);
12841279
break;
@@ -1317,11 +1312,6 @@ export class DevMockHost {
13171312
name,
13181313
...(args !== undefined && { arguments: args }),
13191314
});
1320-
this.emit(session.sessionId, {
1321-
type: 'user-message',
1322-
messageId: userRowMessageId(turn.graph.turnId),
1323-
content,
1324-
});
13251315
session.status = 'running';
13261316
this.emit(session.sessionId, { type: 'status', status: 'running' });
13271317
if (fixture.reply === undefined) {
@@ -1365,16 +1355,11 @@ export class DevMockHost {
13651355
const turn = this.beginTurn(session, content, p.input.type === 'prompt' ? undefined : p.input);
13661356
this.send({ kind: 'turn.submitted', replyTo: p.clientReqId, turnId: turn.graph.turnId });
13671357
if (p.input.type === 'prompt') {
1368-
const result = await this.streamMockReply(session, turn, content);
1358+
const result = await this.streamMockReply(session, content);
13691359
settleTurn(session, turn, result.ok ? 'completed' : 'failed');
13701360
return;
13711361
}
13721362
// Command/shell turns just echo — the mock has no directive execution behind turn.submit.
1373-
this.emit(p.sessionId, {
1374-
type: 'user-message',
1375-
messageId: userRowMessageId(turn.graph.turnId),
1376-
content,
1377-
});
13781363
settleTurn(session, turn, 'completed');
13791364
}
13801365

@@ -1405,6 +1390,13 @@ export class DevMockHost {
14051390
};
14061391
session.graphTurns.push(turn);
14071392
session.runningTurnId = turnId;
1393+
// Echo before graph.changed so a subscribed projection store sees the new leaf row and
1394+
// treats a plain send as continuation, matching the engine dispatcher.
1395+
this.emit(session.sessionId, {
1396+
type: 'user-message',
1397+
messageId: userRowMessageId(turnId),
1398+
content,
1399+
});
14081400
this.send({
14091401
kind: 'conversation.graph.changed',
14101402
sessionId: session.sessionId,
@@ -1420,25 +1412,19 @@ export class DevMockHost {
14201412
content: ContentBlock[],
14211413
): Promise<void> {
14221414
const turn = this.beginTurn(session, content);
1423-
const result = await this.streamMockReply(session, turn, content);
1415+
const result = await this.streamMockReply(session, content);
14241416
settleTurn(session, turn, result.ok ? 'completed' : 'failed');
14251417
if (result.ok) this.sendSuccess(replyTo);
14261418
else this.sendFailure(replyTo, result.message, { reportedInConversation: true });
14271419
}
14281420

14291421
private async streamMockReply(
14301422
session: MockSession,
1431-
turn: MockTurn,
14321423
content: ContentBlock[],
14331424
): Promise<{ ok: true } | { ok: false; message: string }> {
14341425
const text = promptText(content);
14351426
if (text && !session.title) session.title = text.slice(0, 80);
14361427
session.status = 'running';
1437-
this.emit(session.sessionId, {
1438-
type: 'user-message',
1439-
messageId: userRowMessageId(turn.graph.turnId),
1440-
content,
1441-
});
14421428
this.emit(session.sessionId, { type: 'status', status: 'running' });
14431429

14441430
// Cancel/stop bump the session epoch; a stale epoch means this turn was cancelled and the

packages/client/workbench/tests/integration/dev-mock-projection.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
readConversationSeed,
66
} from '@linkcode/client-core';
77
import { userRowMessageId } from '@linkcode/schema';
8+
import { noop } from 'foxact/noop';
89
import { nullthrow } from 'foxts/guard';
910
import { wait } from 'foxts/wait';
1011
import { describe, expect, it } from 'vitest';
@@ -23,7 +24,15 @@ describe('dev mock projection seeding', () => {
2324
// No turn rows and no transcript: nothing to seed, the store runs live-only.
2425
await expect(readConversationSeed(client, source)).resolves.toBeUndefined();
2526

27+
const liveResyncs: ConversationResyncReason[] = [];
28+
createConversationStore(client, sessionId, undefined, {
29+
onResync: (reason) => liveResyncs.push(reason),
30+
}).subscribe(noop);
31+
2632
await client.promptText(sessionId, 'Hello mocked daemon');
33+
await wait(10);
34+
expect(liveResyncs).toEqual(['graph']);
35+
2736
const change = nullthrow(client.latestGraphChange(sessionId));
2837
const leaf = nullthrow(change.activeLeafTurnId);
2938

@@ -38,6 +47,7 @@ describe('dev mock projection seeding', () => {
3847
const store = createConversationStore(client, sessionId, seed, {
3948
onResync: (reason) => resyncs.push(reason),
4049
});
50+
store.subscribe(noop);
4151
const items = store.getSnapshot().items;
4252
// One user row under the turn identity — the live echo folded once, never twice.
4353
expect(items.filter((item) => item.kind === 'message' && item.role === 'user')).toEqual([
@@ -48,6 +58,8 @@ describe('dev mock projection seeding', () => {
4858

4959
// A turn without output renders the prompt-only placeholder under its row.
5060
await client.runShellCommand(sessionId, 'ls');
61+
await wait(10);
62+
expect(resyncs).toEqual([]);
5163
const reseed = await readConversationSeed(client, source);
5264
if (reseed === undefined || !('items' in reseed)) throw new Error('expected a projection seed');
5365
const shellLeaf = nullthrow(client.latestGraphChange(sessionId)?.activeLeafTurnId);

packages/host/engine/src/__tests__/engine-schedule.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ describe('engine schedule wiring', () => {
232232
graphRevision: 1,
233233
activeLeafTurnId: turn.turnId,
234234
});
235+
expect(
236+
h.sent.filter(
237+
(payload) => payload.kind === 'agent.event' && payload.event.type === 'user-message',
238+
),
239+
).toEqual([
240+
expect.objectContaining({
241+
event: expect.objectContaining({
242+
type: 'user-message',
243+
messageId: `msg-${turn.turnId}`,
244+
}),
245+
}),
246+
]);
235247
});
236248

237249
it('reports an unknown schedule as not found', async () => {

packages/host/engine/src/session/orchestrator.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { AdapterFactory, AgentAdapter, BrowserToolsetFactory } from '@linkcode/agent-adapter';
2-
import { nextMessageId } from '@linkcode/agent-adapter';
32
import type {
43
AgentEvent,
54
AgentHistoryCapabilities,
@@ -13,6 +12,7 @@ import type {
1312
SessionInfo,
1413
SessionRecord,
1514
} from '@linkcode/schema';
15+
import { userRowMessageId } from '@linkcode/schema';
1616
import type { Transport } from '@linkcode/transport';
1717
import { createWireMessage } from '@linkcode/transport';
1818
import { Cause, Deferred, Effect, Exit, Scope } from 'effect';
@@ -221,9 +221,15 @@ export class SessionOrchestrator {
221221
input: { type: 'prompt', blocks: promptBlocksFromContent(content) },
222222
});
223223
const result = yield* Effect.sync(() => {
224-
this.events.broadcast(sessionId, session, [
225-
{ type: 'user-message', messageId: nextMessageId(), content },
226-
]);
224+
this.events.broadcast(
225+
sessionId,
226+
session,
227+
session.trackPrompt(
228+
userRowMessageId(intent.turn.turnId),
229+
content,
230+
intent.turn.turnId,
231+
),
232+
);
227233
records.setTitleFromContent(sessionId, content);
228234
}).pipe(
229235
Effect.andThen(

packages/host/engine/src/session/session-input-dispatcher.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,11 @@ export class SessionInputDispatcher {
138138
input.type === 'command'
139139
? `/${input.name}${input.arguments ? ` ${input.arguments}` : ''}`
140140
: `$ ${input.command}`;
141-
events.broadcast(sessionId, session, [
142-
{ type: 'user-message', messageId: echoMessageId, content: [{ type: 'text', text }] },
143-
]);
141+
events.broadcast(
142+
sessionId,
143+
session,
144+
session.trackPrompt(echoMessageId, [{ type: 'text', text }], persistedTurnId),
145+
);
144146
}
145147
}
146148
const responseInput =

0 commit comments

Comments
 (0)