Skip to content

Commit 9df7a9c

Browse files
authored
fix: keep turn ids above the wire-wide max and fold cold transcripts over the active branch (#3922)
* fix(agent-core-v2): floor the human turn clock at the wire-wide max on engine journal reset * fix(kap-server): fold the cold transcript snapshot over the restorable branch chain * fix(agent-core-v2): advance the human turn clock when a turn starts * fix(kap-server): split reused wire turn ids in the live transcript projector * fix(kap-server): adopt the completed cold tip for mid-turn attach events Continuation deltas after a lazy transcript attach belong on the last cold turn, not a newly split export id.
1 parent 7d3f88f commit 9df7a9c

10 files changed

Lines changed: 353 additions & 52 deletions

File tree

‎.changeset/cold-rebuild-turn-id.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Fix new messages occasionally landing at an old position in the conversation after resuming a session.

‎packages/agent-core-v2/src/agent/loop/machine/engine.ts‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import type { Actor, Subscription } from '#human/xstate2';
2828

2929
import { createMachineRequester, type MachineRequester, type MachineRequesterGateDecision } from './requester';
3030
import { createMachineTools, type MachineTools, type ToolResultExtras } from './tools';
31-
import { seededStoreJournal } from './storeJournal';
31+
import { appendedStoreJournal, seededStoreJournal } from './storeJournal';
3232

3333
export type { PromptGateVerdict };
3434

@@ -619,9 +619,8 @@ export function engineJournal(base: SyncStoreJournal | undefined, initialTurnId:
619619
if (initialTurnId <= 0) return memoryJournal();
620620
return seedRecords([turnEnded({ turnId: initialTurnId - 1, outcome: 'done' })]);
621621
}
622-
if (initialTurnId <= 0 || base.readSync().length > 0) return base;
623-
return seededStoreJournal(
624-
base,
625-
seedRecords([turnEnded({ turnId: initialTurnId - 1, outcome: 'done' })]).readSync(),
626-
);
622+
if (initialTurnId <= 0) return base;
623+
const seed = seedRecords([turnEnded({ turnId: initialTurnId - 1, outcome: 'done' })]).readSync();
624+
if (base.readSync().length === 0) return seededStoreJournal(base, seed);
625+
return appendedStoreJournal(base, seed);
627626
}

‎packages/agent-core-v2/src/agent/loop/machine/storeJournal.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,33 @@ export function seededStoreJournal(
8686
settled: () => base.settled(),
8787
};
8888
}
89+
90+
export function appendedStoreJournal(
91+
base: SyncStoreJournal,
92+
seed: readonly JournalRecord[],
93+
): SyncStoreJournal {
94+
let appended = 0;
95+
const baseCount = base.nextSeq();
96+
const trailing = seed.map((record, index) => ({
97+
...record,
98+
branch: base.ref.branch,
99+
seq: baseCount + index,
100+
}));
101+
return {
102+
get ref() {
103+
return base.ref;
104+
},
105+
append: async (input) => {
106+
const entry = await base.append(input);
107+
appended += 1;
108+
return entry;
109+
},
110+
read: async function* () {
111+
for (const record of base.readSync()) yield record;
112+
for (const record of trailing) yield record;
113+
},
114+
readSync: () => [...base.readSync(), ...trailing],
115+
nextSeq: () => baseCount + trailing.length + appended,
116+
settled: () => base.settled(),
117+
};
118+
}

‎packages/agent-core-v2/src/human/agent/slices.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,12 @@ export const turnIndexSlice = createSlice({
119119
reducers: {
120120
[turnStarted.type]: (draft, event: TurnStarted, ctx) => {
121121
draft.turns.push({ turnId: event.turnId, start: ctx.ref });
122+
if (event.turnId >= draft.nextTurnId) draft.nextTurnId = event.turnId + 1;
122123
},
123124
[turnEnded.type]: (draft, event: TurnEnded, ctx) => {
124125
const entry = draft.turns.findLast((turn) => turn.turnId === event.turnId);
125126
if (entry !== undefined) entry.end = ctx.ref;
126-
draft.nextTurnId = event.turnId + 1;
127+
if (event.turnId >= draft.nextTurnId) draft.nextTurnId = event.turnId + 1;
127128
},
128129
},
129130
});

‎packages/agent-core-v2/src/human/test/session/stores.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,19 @@ describe('SessionStores reopen', () => {
217217
expect(restoredMain.getState().turnIndex.nextTurnId).toBe(3);
218218

219219
actor2.stop();
220+
221+
await restoredMain.dispatch(turnStarted({ turnId: 3 }));
222+
await restored.stores.flush();
223+
224+
const crashed = await reopen(restored);
225+
const crashedMain = await crashed.stores.open('main');
226+
expect(crashedMain.getState().turnIndex.nextTurnId).toBe(4);
227+
228+
const actor3 = startAgent(crashedMain);
229+
await runTurn(actor3, crashedMain, 'after-crash', 8);
230+
expect(crashedMain.getState().turnIndex.turns.at(-1)?.turnId).toBe(4);
231+
232+
actor3.stop();
220233
});
221234
});
222235

‎packages/agent-core-v2/test/agent/undo/undo.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,17 +355,17 @@ describe('AgentConversationUndoService', () => {
355355

356356
expect(ctx.agentState.get(turnKey).nextTurnId).toBe(2);
357357

358-
await expect(runTurn(ctx, 'u3')).resolves.toBe(1);
358+
await expect(runTurn(ctx, 'u3')).resolves.toBe(2);
359359

360360
const persisted = await ctx.persistedWireRecords();
361361
expect(
362362
persisted.filter((record) => record.type === 'turn.prompt').map((record) => record['turnId']),
363-
).toEqual([0, 1, 1]);
363+
).toEqual([0, 1, 2]);
364364
expect(
365365
persisted
366366
.filter((record) => record.type === 'agent.turn.started')
367367
.map((record) => record['turnId']),
368-
).toEqual([0, 1, 1]);
368+
).toEqual([0, 1, 2]);
369369

370370
const resumed = createTestAgent(
371371
{ autoConfigure: false, persistence: new InMemoryWireRecordPersistence(persisted) },
@@ -375,14 +375,14 @@ describe('AgentConversationUndoService', () => {
375375
try {
376376
resumed.get(IAgentContextMemoryService);
377377
await resumed.restorePersisted();
378-
expect(resumed.agentState.get(turnKey).nextTurnId).toBe(2);
379-
await expect(runTurn(resumed, 'u4')).resolves.toBe(2);
378+
expect(resumed.agentState.get(turnKey).nextTurnId).toBe(3);
379+
await expect(runTurn(resumed, 'u4')).resolves.toBe(3);
380380
const repersisted = await resumed.persistedWireRecords();
381381
expect(
382382
repersisted
383383
.filter((record) => record.type === 'agent.turn.started')
384384
.map((record) => record['turnId']),
385-
).toEqual([0, 1, 1, 2]);
385+
).toEqual([0, 1, 2, 3]);
386386
} finally {
387387
await resumed.dispose();
388388
}

‎packages/kap-server/src/services/transcript/coreBinding.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface TranscriptBindingLogger {
3232

3333
export interface TranscriptBinding extends IDisposable {
3434
seedPendingInteractions(agentId?: string): void;
35+
syncFromStore(agentId: string): void;
3536
}
3637

3738
export function bindSessionTranscript(
@@ -106,6 +107,13 @@ export function bindSessionTranscript(
106107
return agentHandle === undefined ? [] : legacyApprovalsOf(agentHandle);
107108
},
108109
turn: (turnId) => store.getAgent(agentId)?.getTurn(turnId),
110+
maxOrdinal: () => {
111+
let max = -1;
112+
for (const item of store.getAgent(agentId)?.getItems() ?? []) {
113+
if (item.kind === 'turn' && item.ordinal > max) max = item.ordinal;
114+
}
115+
return max;
116+
},
109117
prompt: (promptId) => store.getAgent(agentId)?.getPrompt(promptId),
110118
resolvePlanRevisionKey: (key) =>
111119
agents.handleOf(agentId)?.accessor.get(IAgentScopeContext).scope(key) ?? key,
@@ -287,6 +295,9 @@ export function bindSessionTranscript(
287295

288296
return {
289297
seedPendingInteractions,
298+
syncFromStore: (agentId) => {
299+
projectors.get(agentId)?.syncFromStore();
300+
},
290301
dispose: () => {
291302
for (const d of disposables) d.dispose();
292303
for (const list of agentDisposables.values()) {

0 commit comments

Comments
 (0)