@khorsheed/dsh-local-agent-kimi 0.2.0-rc.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,7 +10,14 @@ import { join } from 'node:path';
10
10
  import { readdir, stat } from 'node:fs/promises';
11
11
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
12
12
  import { persistChildSession } from '@khorsheed/dsh-local-agent';
13
- import { readKimiTranscript, sumUsageRecords } from './session-view.js';
13
+ import { addTokenUsage, readKimiTranscript, sumUsageRecords } from './session-view.js';
14
+ /**
15
+ * The synthesized result for a call whose result the wire never recorded
16
+ * (a settle-pass fold, or a live reservation kimi never logged). The
17
+ * released-log contract resolves every advertised call before its step
18
+ * closes, so an interrupted call folds with this error result.
19
+ */
20
+ export const KIMI_INTERRUPTED_TOOL_RESULT = '(工具调用被中断,未能记录返回结果)';
14
21
  /** One user-role message event. */
15
22
  function userEvent(text) {
16
23
  return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } });
@@ -23,38 +30,46 @@ export function assistantEvent(blocks, model) {
23
30
  });
24
31
  }
25
32
  /**
26
- * The step the next fold pass would assign in one turn (the max existing
27
- * step + 1). The token-granularity live
28
- * driver reserves its stream merge key here LAZILY at the first delta, so
29
- * tool cards folded before the stream started keep their chronological
30
- * place below it instead of the stream squatting on step 1.
33
+ * The step the next fold pass would assign in one turn: the smallest step
34
+ * nothing holds (never written, never reserved at call time — callers skip
35
+ * their own reservations on top). Filling the smallest free step keeps the
36
+ * stored log dense: the released-log reader admits only dense, strictly
37
+ * sequential steps, and a reservation released mid-round (its content rode an
38
+ * earlier fold) leaves a gap only the next first-free write fills.
31
39
  * @param childSession - the run's child session.
32
40
  * @param turn - the round's turn number.
33
- * @returns the next free step in the turn.
41
+ * @returns the smallest free step in the turn.
34
42
  */
35
43
  export function nextKimiSessionStep(childSession, turn) {
36
- let next = 1;
44
+ const taken = new Set();
37
45
  for (const event of childSession.snapshotEvents()) {
38
46
  const data = event.data;
39
47
  if (data.turn === turn && typeof data.step === 'number')
40
- next = Math.max(next, data.step + 1);
48
+ taken.add(data.step);
41
49
  }
50
+ let next = 1;
51
+ while (taken.has(next))
52
+ next += 1;
42
53
  return next;
43
54
  }
44
55
  /**
45
- * Fold one non-tool transcript line into message blocks. Thinking maps to
46
- * the native `reasoning` block so the standard conversation renders it as
47
- * thinking rather than a `[思考]` text prefix; reply text stays text (content
48
- * is never filtered — kimi's own injections remain visible). Tool lines are
49
- * NOT folded here: they become native `tool/call`/`tool/result` event pairs
50
- * (see the mirror loop), so the standard conversation renders them as tool
51
- * cards instead of `[工具 X]` text.
56
+ * The step a durable write takes: one past the highest step this turn has
57
+ * opened in the log. The released-log reader admits steps only in order —
58
+ * one past the last, never below it — so the coordinate is decided when the
59
+ * step is written, never reserved ahead (a reservation filled later lands
60
+ * below a higher step already written: a gap, then a step out of order).
61
+ * Stream and tool reservations only key the live preview.
52
62
  */
53
- function lineBlocks(line) {
54
- if (line.kind === 'think') {
55
- return [{ type: 'reasoning', text: line.text }];
63
+ export function nextWrittenKimiStep(childSession, turn) {
64
+ let last = 0;
65
+ for (const event of childSession.snapshotEvents()) {
66
+ if (event.type !== 'step/start')
67
+ continue;
68
+ const data = event.data;
69
+ if (data.turn === turn && typeof data.step === 'number' && data.step > last)
70
+ last = data.step;
56
71
  }
57
- return [{ type: 'text', text: line.text }];
72
+ return last + 1;
58
73
  }
59
74
  /** The run-progress delta text for one transcript line (any kind). */
60
75
  export function kimiLineProgressText(line) {
@@ -64,10 +79,14 @@ export function kimiLineProgressText(line) {
64
79
  return `${call}${line.result !== undefined ? ` → ${line.result}` : ''}`;
65
80
  }
66
81
  /**
67
- * Mirror one kimi session's transcript into the dsh subagent session: each
68
- * user prompt becomes a `user/message`, each assistant reply (including
69
- * folded thinking and tool activity) an `assistant/message`, then the events
70
- * are appended to persistence so the child session is viewable.
82
+ * Mirror one kimi session's transcript into a dsh subagent session: each
83
+ * user prompt becomes a `user/message`, and each WIRE STEP (one LLM request:
84
+ * its thinking, reply text and tool calls share the wire's own step number)
85
+ * folds into one dsh step whose `assistant/message` carries the reasoning,
86
+ * text and tool-call blocks together — the host session's own shape, so the
87
+ * standard surfaces (conversation, Context Lens) read one request per LLM
88
+ * call. Then the events are appended to persistence so the child session is
89
+ * viewable.
71
90
  *
72
91
  * A resumed round (or a live poll) passes the already-mirrored
73
92
  * transcript-line count so only the delta is appended — re-mirroring earlier
@@ -77,22 +96,29 @@ export function kimiLineProgressText(line) {
77
96
  * already in the child session, so a round mirrored across several live polls
78
97
  * does not restart step 1 per poll.
79
98
  *
80
- * Every folded step is wrapped in the `step/start`–`step/end` boundary pair
81
- * the live conversation assembler requires: without a boundary the step never
82
- * enters the location index, the assistant message resolves to a turn-level
83
- * location, and the real-time view renders nothing (only a full rebuild,
84
- * which mints step drafts from the explicit coordinates, recovers it). A
85
- * think/assistant line that completes an in-flight stream folds at the
86
- * stream's reserved step instead (see {@link KimiMirrorStreams}), and a
87
- * sequential fold never takes a step a stream reserved.
99
+ * Every folded step is one ATOMIC `step/start`–`step/end` triple, written
100
+ * only when its content lands: the released-log reader admits only dense,
101
+ * strictly sequential steps (one open at a time — a second `step/start`
102
+ * while another step is open fails the session's reload validation), and the
103
+ * live conversation assembler needs the boundary to materialize the step at
104
+ * all. A fold paired with in-flight streams lands at the earliest consumed
105
+ * reservation (see {@link KimiMirrorStreams}), after `finalizeBefore` has
106
+ * closed every stream reservation below it; a sequential fold never takes a
107
+ * step a stream reserved. Mid-round, a wire step the wire has not CLOSED yet
108
+ * (no `step.end`) holds the pass — its parts may still arrive — and so does
109
+ * an unresulted tool call (the reader resolves every advertised call before
110
+ * the step closes; the result merges into the call's line in place, so the
111
+ * next pass sees it). The settle pass folds the remainder with a synthesized
112
+ * interrupted result. Lines the wire never step-tagged (older kimi) fold one
113
+ * by one, immediately, as before.
88
114
  *
89
115
  * Usage accounting is exactly-once ACROSS passes: each `usage.record` is
90
- * tagged with the transcript position it occurred at, and a pass attaches the
91
- * records in the half-open range `(fromLines, newTotal]` — the records that
92
- * trail THIS pass's lines — summed onto the delta's final assistant message.
93
- * (A record flushed only after its line was already mirrored belongs to no
94
- * later pass and is dropped; kimi flushes content and usage together per
95
- * request, so this window is a flush race, not the norm.)
116
+ * tagged with the transcript position it occurred at, a pass owns the records
117
+ * between the old offset and its own consumed end, and inside the window each
118
+ * record rides the unit whose line span encloses it — one record per wire
119
+ * step, landing on its own step's message. (A record raced PAST its content
120
+ * encloses into the next unit instead: attribution off by one in a flush
121
+ * race, totals still exact.)
96
122
  * @param ctx - host context carrying the session persistence service.
97
123
  * @param childSession - the dsh subagent session created for this delegation.
98
124
  * @param homeDir - the `kimi` harness's scoped home.
@@ -100,7 +126,9 @@ export function kimiLineProgressText(line) {
100
126
  * recent session (a one-shot `kimi -p` run always creates a fresh one).
101
127
  * @param fromLines - transcript lines already mirrored into the child session
102
128
  * (0 for the first round); only lines after this offset are appended.
103
- * @returns the new total and the newly mirrored lines' texts.
129
+ * @returns the new mirror offset and the newly mirrored lines' texts. A held
130
+ * unit (open wire step or unresulted tool) stops the offset before itself;
131
+ * the next pass retries from there.
104
132
  */
105
133
  export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId, fromLines = 0, options) {
106
134
  let workspaces;
@@ -164,7 +192,6 @@ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSes
164
192
  }
165
193
  if (transcript === undefined || transcript.lines.length === 0)
166
194
  return { total: 0, texts: [] };
167
- const newTotal = transcript.lines.length;
168
195
  const delta = transcript.lines.slice(fromLines);
169
196
  const observedModel = options?.turn === undefined || transcript.modelTurn === options.turn ? transcript.model : undefined;
170
197
  // The ROUND's tool calls, counted over the transcript lines carrying this
@@ -172,146 +199,212 @@ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSes
172
199
  // whose delta a live poll already drained still owes the round its real
173
200
  // count, and a resume round must not inherit the earlier rounds' calls.
174
201
  const toolCalls = roundToolCalls(transcript.lines, options?.turn);
175
- // No early return on an empty delta: a result that merged into an
176
- // already-mirrored tool line does not change the line count, and the
177
- // backfill below still owes that call its `tool/result` event.
178
- // The pass attaches the usage records whose content ITS lines carry. kimi
179
- // writes a request's `usage.record` BEFORE the content parts it accounts
180
- // for, so a record sitting exactly on the delta boundary (its content is
181
- // entirely inside this pass) must attach here — otherwise incremental
182
- // folds (live mid-run + settle) lose the round's accounting entirely.
183
- const deltaUsage = sumUsageRecords(transcript.usageRecords.filter(record => record.line >= fromLines && record.line <= newTotal));
184
- // Attach the summed usage to the delta's LAST assistant message (text or
185
- // think both carry the round's accounting; tool lines do not).
186
- let lastAssistant = -1;
187
- for (let index = 0; index < delta.length; index += 1) {
188
- const line = delta[index];
189
- if (line !== undefined && line.kind !== 'user' && line.kind !== 'tool')
190
- lastAssistant = index;
202
+ const units = [];
203
+ for (const line of delta) {
204
+ if (line === undefined)
205
+ continue;
206
+ const last = units.at(-1);
207
+ const head = last?.[0];
208
+ if (last !== undefined && head !== undefined
209
+ && line.kind !== 'user' && head.kind !== 'user'
210
+ && line.wireStep !== undefined && line.wireStep === head.wireStep
211
+ && line.turn === head.turn) {
212
+ last.push(line);
213
+ continue;
214
+ }
215
+ units.push([line]);
191
216
  }
192
- // Continue step numbering from the steps already mirrored (assistant
193
- // messages AND tool events both consume steps), so a round mirrored across
194
- // several live polls keeps 1, 2, 3… instead of restarting per poll.
195
- const steps = new Map();
196
- // The child session's own events are the ledger of mirrored tool calls:
197
- // callId → its event (for pairing late results) and the already-settled
198
- // call ids (so a backfill never duplicates a result).
199
- const openCalls = new Map();
200
- const settledCalls = new Set();
217
+ // Mid-round, a unit that may still grow HOLDS the pass: a wire-step group
218
+ // whose step the wire has not closed yet, or a unit whose tool call lacks
219
+ // its result (the released-log contract resolves every advertised call
220
+ // before its step closes; the result merges into the call's line in place,
221
+ // so the next pass sees it). The offset stops before the first such unit
222
+ // and the fold retries once the wire completes it. The settle pass never
223
+ // holds — a result that will never arrive folds as a synthesized
224
+ // interrupted one.
225
+ let holdAt = -1;
226
+ if (options?.settle !== true) {
227
+ for (let index = 0; index < units.length; index += 1) {
228
+ const unit = units[index];
229
+ const head = unit[0];
230
+ const openGroup = head.kind !== 'user'
231
+ && head.wireStep !== undefined
232
+ && !transcript.closedSteps.has(`${head.turn}:${head.wireStep}`);
233
+ if (openGroup || unit.some(line => line.kind === 'tool' && line.result === undefined)) {
234
+ holdAt = index;
235
+ break;
236
+ }
237
+ }
238
+ }
239
+ const foldedUnits = holdAt < 0 ? units : units.slice(0, holdAt);
240
+ const consumedTotal = fromLines + foldedUnits.reduce((n, unit) => n + unit.length, 0);
241
+ // Usage is exactly-once across passes: a pass owns the records at positions
242
+ // [fromLines, consumedTotal] (the boundary included — a record flushed right
243
+ // after its content otherwise falls out of every span). kimi's norm is one
244
+ // usage.record per wire step, right BEFORE the step's content, so a
245
+ // step-tagged unit claims its own line span's records; records no tagged
246
+ // unit claims (untagged spans on older wires, or the boundary) sum onto the
247
+ // pass's last folded content unit — the pre-tagging shape.
248
+ const unitUsage = [];
249
+ const claimed = [];
250
+ let unitStart = fromLines;
251
+ for (const unit of foldedUnits) {
252
+ const start = unitStart;
253
+ unitStart += unit.length;
254
+ const head = unit[0];
255
+ if (head.kind !== 'user' && head.wireStep !== undefined) {
256
+ claimed.push({ from: start, to: unitStart });
257
+ unitUsage.push(sumUsageRecords(transcript.usageRecords.filter(record => record.line >= start && record.line < unitStart)));
258
+ }
259
+ else {
260
+ unitUsage.push(undefined);
261
+ }
262
+ }
263
+ const unclaimedUsage = sumUsageRecords(transcript.usageRecords.filter(record => record.line >= fromLines && record.line <= consumedTotal
264
+ && !claimed.some(span => record.line >= span.from && record.line < span.to)));
265
+ const deltaUsage = sumUsageRecords(transcript.usageRecords.filter(record => record.line >= fromLines && record.line <= consumedTotal));
266
+ // The steps a fold may NOT take: every step already written (the session's
267
+ // own event ledger), consulted per turn. A round mirrored across several
268
+ // live polls keeps its earlier writes, so later polls never reuse them.
269
+ const takenSteps = new Map();
201
270
  for (const event of childSession.snapshotEvents()) {
202
271
  const data = event.data;
203
272
  if (typeof data.turn === 'number' && typeof data.step === 'number') {
204
- steps.set(data.turn, Math.max(steps.get(data.turn) ?? 0, data.step + 1));
205
- }
206
- if (event.type === 'tool/call') {
207
- const call = event.data;
208
- openCalls.set(call.callId, { turn: call.turn, step: call.step, seq: event.seq });
209
- }
210
- else if (event.type === 'tool/result') {
211
- // The call id moved with the V4 message shape: rc.1's native tool-role
212
- // message carries it at the top level, the pre-V4 user-role shape
213
- // wrapped it in the single content block. Read both — a session
214
- // mirrored before the upgrade still owes its old-shape results pairing.
215
- const message = event.data.message;
216
- const id = message?.toolCallId ?? message?.content?.[0]?.toolCallId;
217
- if (id !== undefined) {
218
- openCalls.delete(id);
219
- settledCalls.add(id);
220
- }
273
+ const taken = takenSteps.get(data.turn) ?? new Set();
274
+ taken.add(data.step);
275
+ takenSteps.set(data.turn, taken);
221
276
  }
222
277
  }
223
278
  /**
224
- * The next sequential fold step in one turn: the running counter, shifted
225
- * past every step an in-flight stream reserved (a stream's snapshots and
226
- * its completion fold live at the reserved coordinate — a sequential fold
227
- * landing there would write a second step/start, which the assembler
228
- * rejects).
279
+ * The fold's coordinate: the smallest step of the turn that nothing holds
280
+ * — neither written nor currently reserved. Filling the smallest free step
281
+ * keeps the stored log dense even when a stream reservation below was
282
+ * released mid-round (its content rode an earlier fold's step).
229
283
  */
230
284
  const nextFoldStep = (turn) => {
231
- let step = steps.get(turn) ?? 1;
232
- const reserved = options?.streams?.reservedSteps(turn);
233
- if (reserved !== undefined && reserved.length > 0) {
234
- const skip = new Set(reserved);
235
- while (skip.has(step))
236
- step += 1;
237
- }
238
- steps.set(turn, step + 1);
285
+ const taken = new Set(takenSteps.get(turn));
286
+ for (const reserved of options?.streams?.reservedSteps(turn) ?? [])
287
+ taken.add(reserved);
288
+ let step = 1;
289
+ while (taken.has(step))
290
+ step += 1;
239
291
  return step;
240
292
  };
241
- /** Whether this pass attached its window's usage to a folded message. */
242
- let usageAttached = false;
243
- const texts = [];
244
- // Backfill results that landed after their call was mirrored in an earlier
245
- // pass: the delta window never revisits those lines, so a result that
246
- // arrives late (parallel calls settle out of order) pairs here. The result
247
- // reuses its call's turn/step — no new step is consumed.
248
- for (let index = 0; index < fromLines && index < transcript.lines.length; index += 1) {
249
- const line = transcript.lines[index];
250
- if (line === undefined || line.kind !== 'tool' || line.result === undefined)
251
- continue;
252
- const call = openCalls.get(line.id);
253
- if (call === undefined)
254
- continue;
255
- openCalls.delete(line.id);
256
- settledCalls.add(line.id);
257
- // No second boundary pair here: the call's fold already opened AND closed
258
- // this (turn, step), and the live assembler throws on a duplicate start
259
- // match for the same context. The step stays registered in the location
260
- // index, so the bare result still resolves to the step location — and the
261
- // tool definition never consults it anyway.
262
- childSession.append('tool/result', {
263
- turn: call.turn,
264
- step: call.step,
265
- message: createToolResultMessage({
266
- callId: line.id,
267
- content: [{ type: 'text', text: line.result }],
268
- isError: false,
269
- }),
270
- }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
271
- texts.push(kimiLineProgressText(line));
272
- }
273
- if (delta.length === 0) {
274
- // No new lines this pass: only the late-result backfill above could have
275
- // produced events. Persist those and skip the empty delta loop.
276
- if (texts.length > 0) {
277
- await persistChildSession(ctx, childSession);
293
+ /** Record a fold's coordinate chosen by a stream pairing. */
294
+ const markTaken = (turn, step) => {
295
+ const written = takenSteps.get(turn) ?? new Set();
296
+ written.add(step);
297
+ takenSteps.set(turn, written);
298
+ };
299
+ /** The window's accounting that no fold carried (dropped units' sums). */
300
+ let carryUsage;
301
+ // The pass's last content unit carries the records no tagged unit claimed.
302
+ let lastContentUnit = -1;
303
+ for (let index = foldedUnits.length - 1; index >= 0; index -= 1) {
304
+ if (foldedUnits[index][0]?.kind !== 'user') {
305
+ lastContentUnit = index;
306
+ break;
278
307
  }
279
- return {
280
- total: newTotal,
281
- texts,
282
- ...deltaUsage !== undefined ? { usage: deltaUsage } : {},
283
- ...usageAttached ? { usageAttached: true } : {},
284
- ...observedModel !== undefined ? { model: observedModel } : {},
285
- ...toolCalls !== undefined ? { toolCalls } : {},
286
- };
287
308
  }
288
- for (let index = 0; index < delta.length; index += 1) {
289
- const line = delta[index];
290
- if (line === undefined)
291
- continue;
309
+ const texts = [];
310
+ for (let unitIndex = 0; unitIndex < foldedUnits.length; unitIndex += 1) {
311
+ const unit = foldedUnits[unitIndex];
312
+ const head = unit[0];
313
+ // The unit's own records plus whatever a dropped unit handed forward.
314
+ let usage = addTokenUsage(carryUsage, unitUsage[unitIndex]);
315
+ carryUsage = undefined;
316
+ if (unitIndex === lastContentUnit)
317
+ usage = addTokenUsage(usage, unclaimedUsage);
292
318
  // The wire's turn number (1-based, matching the provider's turn/start
293
319
  // numbering) is the dsh round; no offset is needed because both count the
294
320
  // same rounds.
295
- const turn = line.turn;
296
- if (line.kind === 'user') {
321
+ const turn = head.turn;
322
+ if (head.kind === 'user') {
297
323
  // The live driver appends the round's user/message at turn start (so the
298
324
  // question renders before the streamed think/text instead of after it).
299
325
  // Skip the wire's copy of the same prompt — scoped to THIS turn so two
300
326
  // rounds with identical prompts still fold independently.
301
- if (userAlreadyAppended(childSession, turn, line.text))
327
+ if (userAlreadyAppended(childSession, turn, head.text))
302
328
  continue;
303
- steps.set(turn, 1);
304
- childSession.append('user/message', userEvent(line.text), { surfaceOp: 'append' });
305
- texts.push(line.text);
329
+ childSession.append('user/message', userEvent(head.text), { surfaceOp: 'append' });
330
+ texts.push(head.text);
331
+ carryUsage = usage;
332
+ continue;
333
+ }
334
+ const toolLines = unit.filter((line) => line.kind === 'tool');
335
+ const reasoning = unit.filter(line => line.kind === 'think').map(line => line.text).join('');
336
+ const text = unit.filter(line => line.kind === 'assistant').map(line => line.text).join('');
337
+ const kinds = [
338
+ ...reasoning.trim() === '' ? [] : ['think'],
339
+ ...text.trim() === '' ? [] : ['text'],
340
+ ];
341
+ // Pair the fold with the round's in-flight streams, consuming the kinds
342
+ // present: the fold lands at the earliest consumed reservation, and a
343
+ // kind already gap-sealed keeps its durable triple — its content stays
344
+ // out of this message.
345
+ const pairing = kinds.length === 0 ? undefined : options?.streams?.completeStreams(turn, kinds);
346
+ const sealed = new Set(pairing?.sealed ?? []);
347
+ const blocks = [];
348
+ if (reasoning.trim() !== '' && !sealed.has('think'))
349
+ blocks.push({ type: 'reasoning', text: reasoning });
350
+ if (text.trim() !== '' && !sealed.has('text'))
351
+ blocks.push({ type: 'text', text });
352
+ for (const line of toolLines) {
353
+ blocks.push({ type: 'tool-call', id: line.id, name: line.name, arguments: line.args ?? '' });
306
354
  }
307
- else if (line.kind === 'tool') {
308
- // Native tool card: the call event now, the result event when the wire
309
- // already carries it (else the backfill above pairs it in a later pass).
310
- const reserved = options?.streams?.completeTool?.(line);
311
- const step = reserved?.step ?? nextFoldStep(turn);
312
- steps.set(turn, Math.max(steps.get(turn) ?? 1, step + 1));
313
- if (reserved?.opened !== true)
314
- childSession.append('step/start', { turn, step });
355
+ for (const line of unit)
356
+ texts.push(kimiLineProgressText(line));
357
+ if (blocks.length === 0) {
358
+ // Every content kind already durable via a gap fill and no tool
359
+ // lifecycle to fold: the unit drops; its usage rides the next fold.
360
+ carryUsage = usage;
361
+ continue;
362
+ }
363
+ // A tool reservation only pins chronological order — the fold's step
364
+ // carries the whole lifecycle, so the reserved coordinate frees.
365
+ for (const line of toolLines)
366
+ options?.streams?.releaseTool?.(line);
367
+ let step = pairing?.step ?? nextFoldStep(turn);
368
+ // Close every stream reservation below the fold step first: stored steps
369
+ // must land dense and strictly sequential.
370
+ options?.streams?.finalizeBefore?.(turn, step);
371
+ // Finalization may have WRITTEN a reservation below the provisional
372
+ // coordinate — or RELEASED an empty one without a write. Either way the
373
+ // fold must take the smallest step still free afterwards: writing above
374
+ // a released-but-never-written reservation strands a permanent gap the
375
+ // released-log reader rejects as corruption.
376
+ let sequential = nextKimiSessionStep(childSession, turn);
377
+ const reservedAfter = options?.streams?.reservedSteps(turn) ?? [];
378
+ while (reservedAfter.includes(sequential))
379
+ sequential += 1;
380
+ step = Math.min(step, sequential);
381
+ markTaken(turn, step);
382
+ // The durable coordinate is decided now, in write order (see nextWrittenKimiStep).
383
+ step = nextWrittenKimiStep(childSession, turn);
384
+ // The wire's own request config rides a request/header ahead of the fold
385
+ // (the host session's own shape) — Context Lens reads the child log's
386
+ // config from it instead of showing 未记录. Only a change is logged.
387
+ const requestConfig = unit.map(line => line.kind !== 'user' ? line.requestConfig : undefined).find(config => config !== undefined);
388
+ if (requestConfig?.model !== undefined) {
389
+ appendRequestHeaderIfChanged(childSession, {
390
+ provider: 'kimi-cli',
391
+ model: requestConfig.model,
392
+ ...requestConfig.reasoningEffort === undefined ? {} : { reasoningEffort: requestConfig.reasoningEffort },
393
+ ...requestConfig.maxTokens === undefined ? {} : { maxTokens: requestConfig.maxTokens },
394
+ });
395
+ }
396
+ const model = [...unit].reverse().find(line => line.model !== undefined)?.model;
397
+ childSession.append('step/start', { turn, step });
398
+ childSession.append('assistant/message', {
399
+ turn,
400
+ step,
401
+ message: assistantEvent(blocks, model),
402
+ stream: [],
403
+ ...usage === undefined ? {} : { usage },
404
+ }, { surfaceOp: 'append' });
405
+ // The native tool lifecycle inside the same step: the message advertises
406
+ // every call, then each call resolves before the step closes.
407
+ for (const line of toolLines) {
315
408
  const call = childSession.append('tool/call', {
316
409
  turn,
317
410
  step,
@@ -319,66 +412,28 @@ export async function mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSes
319
412
  name: line.name,
320
413
  arguments: line.args ?? '',
321
414
  });
322
- texts.push(kimiLineProgressText(line));
323
- if (line.result !== undefined) {
324
- childSession.append('tool/result', {
325
- turn,
326
- step,
327
- message: createToolResultMessage({
328
- callId: line.id,
329
- content: [{ type: 'text', text: line.result }],
330
- isError: false,
331
- }),
332
- }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
333
- }
334
- childSession.append('step/end', { turn, step });
335
- }
336
- else {
337
- // A completed think/assistant line pairs with its in-flight stream when
338
- // one is live (the token granularity's per-kind stream): the fold lands
339
- // at the stream's reserved step, finalizing the snapshots — the step
340
- // opens only when no snapshot ever landed (a fast item that stayed
341
- // under the throttle), and closes here. A (turn, step) never takes a
342
- // second step/start.
343
- const completion = options?.streams?.completeStream(line);
344
- if (completion !== undefined) {
345
- steps.set(turn, Math.max(steps.get(turn) ?? 1, completion.step + 1));
346
- if (!completion.opened)
347
- childSession.append('step/start', { turn, step: completion.step });
348
- if (index === lastAssistant && deltaUsage !== undefined)
349
- usageAttached = true;
350
- childSession.append('assistant/message', {
351
- turn,
352
- step: completion.step,
353
- message: assistantEvent(lineBlocks(line), line.model),
354
- stream: [],
355
- ...index === lastAssistant && deltaUsage !== undefined ? { usage: deltaUsage } : {},
356
- }, { surfaceOp: 'append' });
357
- childSession.append('step/end', { turn, step: completion.step });
358
- texts.push(kimiLineProgressText(line));
359
- continue;
360
- }
361
- const step = nextFoldStep(turn);
362
- childSession.append('step/start', { turn, step });
363
- if (index === lastAssistant && deltaUsage !== undefined)
364
- usageAttached = true;
365
- childSession.append('assistant/message', {
415
+ childSession.append('tool/result', {
366
416
  turn,
367
417
  step,
368
- message: assistantEvent(lineBlocks(line), line.model),
369
- stream: [],
370
- ...index === lastAssistant && deltaUsage !== undefined ? { usage: deltaUsage } : {},
371
- }, { surfaceOp: 'append' });
372
- childSession.append('step/end', { turn, step });
373
- texts.push(kimiLineProgressText(line));
418
+ message: createToolResultMessage({
419
+ callId: line.id,
420
+ content: [{ type: 'text', text: line.result ?? KIMI_INTERRUPTED_TOOL_RESULT }],
421
+ isError: line.result === undefined,
422
+ }),
423
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
374
424
  }
425
+ childSession.append('step/end', { turn, step });
375
426
  }
376
427
  await persistChildSession(ctx, childSession);
428
+ // The window's accounting that folded nowhere: dropped units' carry plus the
429
+ // unclaimed sum when no content unit folded at all.
430
+ const remainder = addTokenUsage(carryUsage, lastContentUnit === -1 ? unclaimedUsage : undefined);
377
431
  return {
378
- total: newTotal,
432
+ total: consumedTotal,
379
433
  texts,
380
434
  ...deltaUsage !== undefined ? { usage: deltaUsage } : {},
381
- ...usageAttached ? { usageAttached: true } : {},
435
+ ...deltaUsage !== undefined && remainder === undefined ? { usageAttached: true } : {},
436
+ ...remainder !== undefined ? { usageUnattached: remainder } : {},
382
437
  ...observedModel !== undefined ? { model: observedModel } : {},
383
438
  ...toolCalls !== undefined ? { toolCalls } : {},
384
439
  };
@@ -405,6 +460,27 @@ export function roundToolCalls(lines, turn) {
405
460
  }
406
461
  return count === 0 ? undefined : { count, byName };
407
462
  }
463
+ /**
464
+ * Write the fold's request/header when the wire's declared config differs from
465
+ * the child log's last one (the host logs changed snapshots, not per-call
466
+ * duplicates): first write is `initial`, a differing one `change`, an
467
+ * identical one skipped.
468
+ */
469
+ function appendRequestHeaderIfChanged(childSession, config) {
470
+ const serialized = JSON.stringify(config);
471
+ let last;
472
+ for (const event of childSession.snapshotEvents()) {
473
+ if (event.type === 'request/header') {
474
+ last = JSON.stringify(event.data.header?.config);
475
+ }
476
+ }
477
+ if (last === serialized)
478
+ return;
479
+ childSession.append('request/header', {
480
+ header: { config },
481
+ reason: last === undefined ? 'initial' : 'change',
482
+ });
483
+ }
408
484
  /**
409
485
  * Whether the turn already carries a user/message with this exact text (the
410
486
  * live driver's round-start append). Scoped to the events at or after this