@cjhyy/code-shell-core 0.6.0-rc.7 → 0.6.0-rc.9

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.
@@ -9,9 +9,10 @@
9
9
  export interface SteerItem {
10
10
  id: string;
11
11
  text: string;
12
+ clientMessageId?: string;
12
13
  }
13
14
  /** Append a steer entry. Blank text is dropped (returns the list unchanged). */
14
- export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string): SteerItem[];
15
+ export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string, clientMessageId?: string): SteerItem[];
15
16
  /**
16
17
  * Take everything currently queued and clear the list. Returns the drained
17
18
  * entries (in order) and the now-empty remainder. The turn loop calls this at
@@ -1,9 +1,9 @@
1
1
  /** Append a steer entry. Blank text is dropped (returns the list unchanged). */
2
- export function enqueueSteerItem(list, id, text) {
2
+ export function enqueueSteerItem(list, id, text, clientMessageId) {
3
3
  const t = text?.trim();
4
4
  if (!id || !t)
5
5
  return list;
6
- return [...list, { id, text: t }];
6
+ return [...list, { id, text: t, ...(clientMessageId ? { clientMessageId } : {}) }];
7
7
  }
8
8
  /**
9
9
  * Take everything currently queued and clear the list. Returns the drained
@@ -4,13 +4,14 @@
4
4
  * Following Claude Code's po_() pattern:
5
5
  * pre_check → model_call → post_check → tool_exec → context_mgmt → hook_notify → next turn
6
6
  */
7
- import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult } from "../types.js";
7
+ import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult, TokenUsage } from "../types.js";
8
8
  import type { SteerItem } from "./steer-queue.js";
9
9
  import { ModelFacade } from "./model-facade.js";
10
10
  import { ToolExecutor } from "../tool-system/executor.js";
11
11
  import { ContextManager } from "../context/manager.js";
12
12
  import { HookRegistry } from "../hooks/registry.js";
13
13
  import { Transcript } from "../session/transcript.js";
14
+ import { type CumulativeUsageCounters } from "./session-usage.js";
14
15
  import { type GoalConfig, type GoalExtension } from "./goal.js";
15
16
  export interface TurnLoopConfig {
16
17
  maxTurns: number;
@@ -18,6 +19,12 @@ export interface TurnLoopConfig {
18
19
  tokenBudget?: number;
19
20
  onStream?: StreamCallback;
20
21
  signal?: AbortSignal;
22
+ /**
23
+ * Image-bearing messages added for the current request and not yet consumed
24
+ * by the model. TurnLoop preserves their base64 for one successful model
25
+ * response, then downgrades them to placeholders in the working history.
26
+ */
27
+ freshImageMessages?: Iterable<Message>;
21
28
  /**
22
29
  * Fired after each turn boundary is recorded. Lets the engine flush an
23
30
  * up-to-date snapshot to state.json mid-run, so a long run doesn't leave
@@ -76,6 +83,11 @@ export interface TurnLoopDeps {
76
83
  before: number;
77
84
  after: number;
78
85
  } | null;
86
+ /**
87
+ * Records one LLM response into the session-monotonic cumulative counters.
88
+ * Returns the updated counters so usage_update can carry both metric scopes.
89
+ */
90
+ recordCumulativeUsage?: (usage: TokenUsage) => CumulativeUsageCounters;
79
91
  /**
80
92
  * Reads/clears any user messages queued for THIS session via the steering
81
93
  * channel (Engine.enqueueSteer) while a run is in flight. Consumed at the top
@@ -84,7 +96,13 @@ export interface TurnLoopDeps {
84
96
  * Returns [] when nothing is queued. Wired by Engine; absent in standalone
85
97
  * tests (turn loop tolerates undefined).
86
98
  */
87
- consumeSteer?: () => SteerItem[];
99
+ consumeSteer?: (source?: "normal_step" | "finalize_backfill") => SteerItem[];
100
+ /**
101
+ * Execution-level idempotency guard for host-supplied user/steer intents.
102
+ * Returns false only when a present clientMessageId has already entered this
103
+ * run or the persisted transcript. Messages without an id bypass this guard.
104
+ */
105
+ claimClientMessageId?: (clientMessageId: string, source: "steer") => boolean;
88
106
  /**
89
107
  * Clear this session's PERSISTED goal (state.activeGoal) and drop the
90
108
  * in-flight goal-stop hook, so a user-initiated cancel_goal both stops the
@@ -121,6 +139,9 @@ export declare class TurnLoop {
121
139
  private currentTurnLog;
122
140
  /** Last emitted ctx token estimate; used to skip no-op usage_update events. */
123
141
  private lastCtxEmit;
142
+ private currentTurnUsage;
143
+ private currentCumulativeUsage;
144
+ private readonly pendingImageMessages;
124
145
  /**
125
146
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
126
147
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -178,6 +199,9 @@ export declare class TurnLoop {
178
199
  * for one Stop still writes a single marker.
179
200
  */
180
201
  private markStopped;
202
+ private prepareMessagesForModel;
203
+ private markPendingImagesConsumed;
204
+ private trackFreshImageMessage;
181
205
  private emitHook;
182
206
  /**
183
207
  * Emit a usage_update so the UI ctx bar reflects current message-array
@@ -187,6 +211,7 @@ export declare class TurnLoop {
187
211
  * promptTokens to override our estimate).
188
212
  */
189
213
  private emitCtxFromMessages;
214
+ private recordResponseUsage;
190
215
  private emitCtxFromUsage;
191
216
  /**
192
217
  * Run the multi-turn agent loop until completion.
@@ -198,6 +223,7 @@ export declare class TurnLoop {
198
223
  */
199
224
  private callModelWithFallback;
200
225
  get currentTurn(): number;
226
+ private consumeQueuedSteer;
201
227
  /**
202
228
  * Generate synthetic error tool_results for any dangling tool_use blocks
203
229
  * that never received results (e.g. because the API call failed).
@@ -11,12 +11,13 @@ import { ContextLimitError } from "../exceptions.js";
11
11
  import { logger } from "../logging/logger.js";
12
12
  import { checkTokenBudget, createBudgetTracker } from "./token-budget.js";
13
13
  import { StreamingToolQueue } from "./streaming-tool-queue.js";
14
- import { estimateTokens } from "../context/compaction.js";
14
+ import { downgradeImagePayloadsInHistory, estimateTokens, messageHasBase64ImagePayload, } from "../context/compaction.js";
15
15
  import { isTruncatedStop } from "../llm/stop-reason.js";
16
16
  import { isAbortError } from "../llm/client-base.js";
17
17
  import { crossedReactiveThreshold } from "./reactive-threshold.js";
18
18
  import { COMPLETE_GOAL_TOOL_NAME } from "../tool-system/builtin/complete-goal.js";
19
19
  import { CANCEL_GOAL_TOOL_NAME } from "../tool-system/builtin/cancel-goal.js";
20
+ import { addTokenUsage, cacheHitRateFromUsage, cumulativeCacheHitRate, } from "./session-usage.js";
20
21
  import { createGoalBudgetTracker, recordGoalUsage, goalBudgetExceeded, applyGoalExtension, limitProximity, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "./goal.js";
21
22
  /**
22
23
  * 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
@@ -29,7 +30,7 @@ export function toolResultToBlock(result) {
29
30
  tool_use_id: result.id,
30
31
  content: result.error
31
32
  ? `Error: ${result.error}`
32
- : result.contentBlocks ?? (result.result ?? "(no output)"),
33
+ : (result.contentBlocks ?? result.result ?? "(no output)"),
33
34
  };
34
35
  if (result.isError || result.error)
35
36
  block.is_error = true;
@@ -50,6 +51,15 @@ export class TurnLoop {
50
51
  currentTurnLog = logger;
51
52
  /** Last emitted ctx token estimate; used to skip no-op usage_update events. */
52
53
  lastCtxEmit = -1;
54
+ currentTurnUsage = {
55
+ promptTokens: 0,
56
+ completionTokens: 0,
57
+ totalTokens: 0,
58
+ cacheReadTokens: 0,
59
+ cacheCreationTokens: 0,
60
+ };
61
+ currentCumulativeUsage;
62
+ pendingImageMessages = new Set();
53
63
  /**
54
64
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
55
65
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -145,6 +155,9 @@ export class TurnLoop {
145
155
  config) {
146
156
  this.deps = deps;
147
157
  this.config = config;
158
+ for (const msg of this.config.freshImageMessages ?? []) {
159
+ this.pendingImageMessages.add(msg);
160
+ }
148
161
  // Wrap onStream so a single throwing handler can't silently break
149
162
  // the channel for the rest of the run. A 2026-05-25 incident saw a
150
163
  // sub-agent's events stop reaching the renderer ~23s into its run —
@@ -197,6 +210,38 @@ export class TurnLoop {
197
210
  return;
198
211
  this.deps.transcript.appendTurnStopped();
199
212
  }
213
+ prepareMessagesForModel(messages) {
214
+ const preserveMessages = this.pendingImageMessages.size > 0 ? this.pendingImageMessages : undefined;
215
+ const result = downgradeImagePayloadsInHistory(messages, { preserveMessages });
216
+ if (result.replacedCount > 0) {
217
+ this.currentTurnLog.info("context.image_payload_downgrade", {
218
+ cat: "context",
219
+ images: result.replacedCount,
220
+ pendingFresh: this.pendingImageMessages.size,
221
+ });
222
+ }
223
+ return result.messages;
224
+ }
225
+ markPendingImagesConsumed(messages) {
226
+ if (this.pendingImageMessages.size === 0)
227
+ return messages;
228
+ const consumedMessages = this.pendingImageMessages.size;
229
+ this.pendingImageMessages.clear();
230
+ const result = downgradeImagePayloadsInHistory(messages);
231
+ if (result.replacedCount > 0) {
232
+ this.currentTurnLog.info("context.image_payload_consumed", {
233
+ cat: "context",
234
+ images: result.replacedCount,
235
+ messages: consumedMessages,
236
+ });
237
+ }
238
+ return result.messages;
239
+ }
240
+ trackFreshImageMessage(message) {
241
+ if (messageHasBase64ImagePayload(message)) {
242
+ this.pendingImageMessages.add(message);
243
+ }
244
+ }
200
245
  async emitHook(event, data = {}) {
201
246
  return this.deps.hooks.emit(event, {
202
247
  ...data,
@@ -238,6 +283,10 @@ export class TurnLoop {
238
283
  this.lastCtxEmit = ctx;
239
284
  this.config.onStream({ type: "usage_update", promptTokens: ctx });
240
285
  }
286
+ recordResponseUsage(usage) {
287
+ this.currentTurnUsage = addTokenUsage(this.currentTurnUsage, usage);
288
+ this.currentCumulativeUsage = this.deps.recordCumulativeUsage?.(usage);
289
+ }
241
290
  emitCtxFromUsage(usage, messages) {
242
291
  if (!this.config.onStream)
243
292
  return;
@@ -254,23 +303,46 @@ export class TurnLoop {
254
303
  derivedOverhead: overhead,
255
304
  prev: this.lastCtxEmit,
256
305
  });
257
- if (promptTokens === this.lastCtxEmit)
258
- return;
259
- this.lastCtxEmit = promptTokens;
306
+ const promptChanged = promptTokens !== this.lastCtxEmit;
307
+ if (promptChanged)
308
+ this.lastCtxEmit = promptTokens;
260
309
  // Forward the provider's cache counts so the UI can show a hit rate. Only
261
310
  // attach fields the provider actually reported — a spread keeps them off
262
311
  // the event entirely when undefined, so the renderer can tell "no cache
263
312
  // info this turn" from "0 cached". Estimate-path emits don't call this and
264
313
  // so carry no cache fields (correct: an estimate has no cache reading).
314
+ const singleTurnCacheHitRate = cacheHitRateFromUsage(this.currentTurnUsage);
315
+ const cumulative = this.currentCumulativeUsage;
316
+ const cumulativeHitRate = cumulative ? cumulativeCacheHitRate(cumulative) : undefined;
317
+ if (!promptChanged && singleTurnCacheHitRate === undefined && cumulativeHitRate === undefined) {
318
+ return;
319
+ }
265
320
  this.config.onStream({
266
321
  type: "usage_update",
267
322
  promptTokens,
268
- ...(usage.cacheReadTokens !== undefined
269
- ? { cacheReadTokens: usage.cacheReadTokens }
270
- : {}),
323
+ ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}),
271
324
  ...(usage.cacheCreationTokens !== undefined
272
325
  ? { cacheCreationTokens: usage.cacheCreationTokens }
273
326
  : {}),
327
+ singleTurnPromptTokens: this.currentTurnUsage.promptTokens,
328
+ singleTurnCacheReadTokens: this.currentTurnUsage.cacheReadTokens ?? 0,
329
+ singleTurnCacheCreationTokens: this.currentTurnUsage.cacheCreationTokens ?? 0,
330
+ ...(singleTurnCacheHitRate !== undefined ? { singleTurnCacheHitRate } : {}),
331
+ ...(cumulative
332
+ ? {
333
+ cumulativePromptTokens: cumulative.cumulativePromptTokens,
334
+ cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
335
+ cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
336
+ ...(cumulativeHitRate !== undefined
337
+ ? { cumulativeCacheHitRate: cumulativeHitRate }
338
+ : {}),
339
+ // Legacy aliases for existing renderer builds. New UI code reads the
340
+ // cumulative* fields above.
341
+ sessionPromptTokens: cumulative.cumulativePromptTokens,
342
+ sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
343
+ sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
344
+ }
345
+ : {}),
274
346
  });
275
347
  }
276
348
  /**
@@ -300,6 +372,13 @@ export class TurnLoop {
300
372
  try {
301
373
  while (this.turnCount < this.config.maxTurns) {
302
374
  this.turnCount++;
375
+ this.currentTurnUsage = {
376
+ promptTokens: 0,
377
+ completionTokens: 0,
378
+ totalTokens: 0,
379
+ cacheReadTokens: 0,
380
+ cacheCreationTokens: 0,
381
+ };
303
382
  // Abort fast-path: bail at the loop TOP before doing any per-turn work.
304
383
  // Without this, an aborted child (parent abort, or the 30min per-call
305
384
  // registry timeout) would run a full contextManager.manageAsync (itself
@@ -319,14 +398,7 @@ export class TurnLoop {
319
398
  // they join THIS step's request — no abort, no lost in-flight work. Same
320
399
  // loop-top user-push pattern as turnStartInjection / turn-limit warnings
321
400
  // below. Push to transcript too so they persist + survive resume.
322
- const steered = this.deps.consumeSteer?.() ?? [];
323
- for (const { id, text } of steered) {
324
- if (!text)
325
- continue;
326
- messages.push({ role: "user", content: text });
327
- this.deps.transcript.appendMessage("user", text);
328
- this.config.onStream?.({ type: "steer_injected", text, id });
329
- }
401
+ this.consumeQueuedSteer(messages, "normal_step");
330
402
  const state = initialTurnState(this.turnCount);
331
403
  // Per-turn correlation ID. Every log written through `tlog` (or any
332
404
  // child derived from it) is stamped with `turn` + `turnId`, so
@@ -378,7 +450,11 @@ export class TurnLoop {
378
450
  // Goal mode: announce once when nearing EITHER stop ceiling (turns or
379
451
  // stop-blocks) so the UI can offer a "再续" button while still live.
380
452
  this.maybeAnnounceApproachingLimit();
381
- // Pre-check: context management (async may trigger LLM summarization)
453
+ // Pre-check: downgrade image payloads that have already had their one
454
+ // model-consumption turn, then run context management. Fresh images in
455
+ // pendingImageMessages are preserved through this next model request.
456
+ messages = this.prepareMessagesForModel(messages);
457
+ // Context management (async — may trigger LLM summarization)
382
458
  messages = await this.deps.contextManager.manageAsync(messages);
383
459
  // manageAsync can itself issue an LLM summarization call lasting several
384
460
  // seconds; if the signal aborted during it, stop here rather than
@@ -468,8 +544,9 @@ export class TurnLoop {
468
544
  return { text: finalText, reason: "model_error", messages };
469
545
  }
470
546
  }
471
- // UI ctx bar: prefer the provider's authoritative promptTokens.
547
+ // Record the response once into current-turn and whole-session counters.
472
548
  if (response.usage?.promptTokens !== undefined) {
549
+ this.recordResponseUsage(response.usage);
473
550
  this.emitCtxFromUsage(response.usage, messages);
474
551
  }
475
552
  // Feed actual token usage back to the context manager so subsequent
@@ -478,6 +555,7 @@ export class TurnLoop {
478
555
  if (response.usage?.promptTokens !== undefined) {
479
556
  this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length);
480
557
  }
558
+ messages = this.markPendingImagesConsumed(messages);
481
559
  // Truncation that cut off a TOOL CALL: the model overflowed
482
560
  // max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
483
561
  // Write whose `content` was clipped, leaving file_path unset). Executing
@@ -506,6 +584,7 @@ export class TurnLoop {
506
584
  response.toolCalls.length === 0 &&
507
585
  response.text) {
508
586
  let combinedText = response.text;
587
+ let continuedResponse = false;
509
588
  for (let retry = 0; retry < 3; retry++) {
510
589
  // Don't fire another continuation call if the user cancelled — without
511
590
  // this an abort during a truncated response could still issue up to 3
@@ -522,7 +601,11 @@ export class TurnLoop {
522
601
  },
523
602
  ];
524
603
  try {
525
- const contResponse = await this.deps.model.call(this.deps.systemPrompt, contMessages, this.deps.tools, this.config.onStream, this.config.signal);
604
+ const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
605
+ if (contResponse.usage?.promptTokens !== undefined) {
606
+ this.recordResponseUsage(contResponse.usage);
607
+ continuedResponse = true;
608
+ }
526
609
  combinedText += contResponse.text;
527
610
  if (!isTruncatedStop(contResponse.stopReason) || contResponse.toolCalls.length > 0) {
528
611
  response = { ...contResponse, text: combinedText };
@@ -534,10 +617,9 @@ export class TurnLoop {
534
617
  }
535
618
  }
536
619
  response = { ...response, text: combinedText };
537
- }
538
- // After any continuation, send latest usage so ctx bar reflects real context
539
- if (response.usage?.promptTokens !== undefined) {
540
- this.emitCtxFromUsage(response.usage, messages);
620
+ if (continuedResponse && response.usage?.promptTokens !== undefined) {
621
+ this.emitCtxFromUsage(response.usage, messages);
622
+ }
541
623
  }
542
624
  // Goal-mode run-scoped accounting: add this turn's total token usage
543
625
  // (prompt + completion) to the running total. Done after continuation so
@@ -587,6 +669,9 @@ export class TurnLoop {
587
669
  hasToolUse: false,
588
670
  });
589
671
  messages.push({ role: "assistant", content: finalText });
672
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
673
+ continue;
674
+ }
590
675
  // on_stop seam: the model wants to stop. Give handlers (Goal mode)
591
676
  // a chance to BLOCK termination and keep the agent working. A
592
677
  // handler returning continueSession=true injects its messages and
@@ -668,10 +753,16 @@ export class TurnLoop {
668
753
  });
669
754
  }
670
755
  this.stopBlockCount = 0;
756
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
757
+ continue;
758
+ }
671
759
  return { text: finalText, reason: "completed", messages };
672
760
  }
673
761
  // Tool execution phase
674
- tlog.info("turn.tool_use", { cat: "turn", tools: response.toolCalls.map((t) => t.toolName) });
762
+ tlog.info("turn.tool_use", {
763
+ cat: "turn",
764
+ tools: response.toolCalls.map((t) => t.toolName),
765
+ });
675
766
  const toolCalls = response.toolCalls.slice(0, this.config.maxToolCallsPerTurn);
676
767
  // Per-turn cap: any calls beyond maxToolCallsPerTurn are NOT executed and
677
768
  // NOT added to the assistant message below, so the model never sees a
@@ -734,7 +825,9 @@ export class TurnLoop {
734
825
  });
735
826
  });
736
827
  }
737
- messages.push({ role: "user", content: resultBlocks });
828
+ const toolResultMessage = { role: "user", content: resultBlocks };
829
+ messages.push(toolResultMessage);
830
+ this.trackFreshImageMessage(toolResultMessage);
738
831
  // B-3: tell the model which of its requested tool calls were dropped by
739
832
  // the per-turn cap so it can re-issue them, instead of silently assuming
740
833
  // they ran. Appended to the same user message that carries the results.
@@ -773,6 +866,9 @@ export class TurnLoop {
773
866
  tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
774
867
  this.stopBlockCount = 0;
775
868
  this.deps.clearPersistedGoal?.();
869
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
870
+ continue;
871
+ }
776
872
  return { text: finalText, reason: "completed", messages };
777
873
  }
778
874
  // Goal mode: user-initiated cancellation. cancel_goal is the "strong
@@ -785,6 +881,9 @@ export class TurnLoop {
785
881
  tlog.info("turn.goal_user_cancelled", { cat: "goal" });
786
882
  this.stopBlockCount = 0;
787
883
  this.deps.clearPersistedGoal?.();
884
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
885
+ continue;
886
+ }
788
887
  return { text: finalText, reason: "completed", messages };
789
888
  }
790
889
  // Token budget check
@@ -801,6 +900,9 @@ export class TurnLoop {
801
900
  message: { role: "assistant", content: finalText },
802
901
  });
803
902
  messages.push({ role: "assistant", content: finalText });
903
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
904
+ continue;
905
+ }
804
906
  return { text: finalText, reason: "completed", messages };
805
907
  }
806
908
  if (budgetDecision === "nudge") {
@@ -877,6 +979,8 @@ export class TurnLoop {
877
979
  maxTurns: this.config.maxTurns,
878
980
  turnCount: this.turnCount,
879
981
  });
982
+ this.consumeQueuedSteer(messages, "finalize_backfill");
983
+ messages = this.prepareMessagesForModel(messages);
880
984
  messages = this.deps.contextManager.manage(messages);
881
985
  messages.push({
882
986
  role: "user",
@@ -884,8 +988,9 @@ export class TurnLoop {
884
988
  });
885
989
  this.emitCtxFromMessages(messages);
886
990
  try {
887
- const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, messages, [], // No tools available for summary turn
991
+ const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
888
992
  this.config.onStream, this.config.signal);
993
+ messages = this.markPendingImagesConsumed(messages);
889
994
  if (summaryResponse.text) {
890
995
  finalText = summaryResponse.text;
891
996
  }
@@ -973,6 +1078,28 @@ export class TurnLoop {
973
1078
  get currentTurn() {
974
1079
  return this.turnCount;
975
1080
  }
1081
+ consumeQueuedSteer(messages, source) {
1082
+ const steered = this.deps.consumeSteer?.(source) ?? [];
1083
+ let consumed = false;
1084
+ for (const { id, text, clientMessageId } of steered) {
1085
+ if (!text)
1086
+ continue;
1087
+ if (clientMessageId && this.deps.claimClientMessageId?.(clientMessageId, "steer") === false) {
1088
+ logger.info("steer.submit.duplicate_ignored", {
1089
+ clientMessageId,
1090
+ steerId: id,
1091
+ sessionId: this.deps.sessionId,
1092
+ source,
1093
+ });
1094
+ continue;
1095
+ }
1096
+ consumed = true;
1097
+ messages.push({ role: "user", content: text });
1098
+ this.deps.transcript.appendMessage("user", text, { steerId: id, clientMessageId });
1099
+ this.config.onStream?.({ type: "steer_injected", text, id });
1100
+ }
1101
+ return consumed;
1102
+ }
976
1103
  /**
977
1104
  * Generate synthetic error tool_results for any dangling tool_use blocks
978
1105
  * that never received results (e.g. because the API call failed).
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export declare const VERSION = "0.6.0-rc.7";
6
+ export declare const VERSION = "0.6.0-rc.8";
7
7
  export type { Message, ContentBlock, ToolDefinition, ToolCall, ToolResult, RegisteredTool, TranscriptEvent, TranscriptEventType, SessionState, SessionStatus, TokenUsage, CompiledInput, PermissionDecision, PermissionMode, PermissionRule, TurnPhase, TurnResult, TerminalReason, StreamEvent, StreamCallback, LLMConfig, ClientDefaults, LLMResponse, Settings, MCPServerConfig, } from "./types.js";
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  export { Engine, loadAgentDefinitionsForCwd } from "./engine/engine.js";
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Public API exports.
5
5
  */
6
- export const VERSION = "0.6.0-rc.7";
6
+ export const VERSION = "0.6.0-rc.8";
7
7
  // ─── Exceptions ──────────────────────────────────────────────────
8
8
  export { FrameworkError, LLMError, LLMRateLimitError, ContextLimitError, ToolError, ToolNotFoundError, ToolExecutionError, ToolTimeoutError, PermissionDeniedError, SessionError, TranscriptError, ConfigError, SandboxUnavailableError, } from "./exceptions.js";
9
9
  // ─── Engine (primary API) ────────────────────────────────────────
@@ -18,6 +18,8 @@ export interface TurnOpts {
18
18
  * completion notification) rather than the user's own input — persisted so
19
19
  * the disk reader skips it as a user bubble on replay. See Engine.run. */
20
20
  injected?: boolean;
21
+ /** Stable id for this user-intent; forwarded to Engine.run for idempotency. */
22
+ clientMessageId?: string;
21
23
  }
22
24
  /**
23
25
  * One ChatSession per UI chat tab. Owns a single Engine, an AbortController
@@ -169,6 +169,7 @@ export class ChatSession {
169
169
  onStream,
170
170
  goal: next.opts.goal,
171
171
  injected: next.opts.injected,
172
+ clientMessageId: next.opts.clientMessageId,
172
173
  });
173
174
  this.lastActivityAt = Date.now();
174
175
  next.resolve(result);
@@ -85,7 +85,10 @@ export declare class AgentClient {
85
85
  * the desktop preload's steer(); without it the SDK can't reach the method
86
86
  * (request() is private) — the protocol was asymmetric after the steer merge.
87
87
  */
88
- steer(sessionId: string, text: string, id?: string): Promise<void>;
88
+ steer(sessionId: string, text: string, id?: string, clientMessageId?: string): Promise<{
89
+ accepted: boolean;
90
+ id?: string;
91
+ }>;
89
92
  /**
90
93
  * Revoke a still-pending steer entry by id (before the loop consumes it).
91
94
  * Returns false when the loop already consumed it (not an error — it will
@@ -125,8 +125,14 @@ export class AgentClient {
125
125
  * the desktop preload's steer(); without it the SDK can't reach the method
126
126
  * (request() is private) — the protocol was asymmetric after the steer merge.
127
127
  */
128
- async steer(sessionId, text, id) {
129
- await this.request(Methods.Steer, { sessionId, text, id });
128
+ async steer(sessionId, text, id, clientMessageId) {
129
+ const res = (await this.request(Methods.Steer, {
130
+ sessionId,
131
+ text,
132
+ id,
133
+ clientMessageId,
134
+ }));
135
+ return { accepted: res?.accepted === true, id: res?.id };
130
136
  }
131
137
  /**
132
138
  * Revoke a still-pending steer entry by id (before the loop consumes it).
@@ -180,14 +180,11 @@ export declare class AgentServer {
180
180
  */
181
181
  private requestApprovalFromClient;
182
182
  /**
183
- * Ask the client to answer a question from the agent (legacy single-engine path).
184
- */
185
- /**
186
- * Per-session AskUserQuestion for the chatManager path. Mirrors
187
- * requestAskUserFromClient but resolves via the SESSION's pendingApprovals
188
- * (the chatManager approve handler looks there, keyed by sessionId+requestId)
189
- * and tags the notify with sessionId so the renderer routes the question to
190
- * the right chat tab.
183
+ * Per-session AskUserQuestion for the chatManager path. Resolves via the
184
+ * SESSION's pendingApprovals (the chatManager approve handler looks there,
185
+ * keyed by sessionId+requestId) and tags the notify with sessionId so the
186
+ * renderer routes the question to the right chat tab. This intentionally has
187
+ * no wall-clock timeout; Stop/cancel drains the pending ask.
191
188
  */
192
189
  private requestAskUserForSession;
193
190
  /**
@@ -206,6 +203,11 @@ export declare class AgentServer {
206
203
  * parse into { ok, count?, error? }. Degrades to ok:false on timeout/malformed.
207
204
  */
208
205
  private requestCredentialInjectForSession;
206
+ /**
207
+ * Ask the client to answer a question from the agent (legacy single-engine
208
+ * path). This intentionally has no wall-clock timeout; Stop/cancel drains
209
+ * the pending ask.
210
+ */
209
211
  private requestAskUserFromClient;
210
212
  private notify;
211
213
  /**
@@ -220,11 +222,10 @@ export declare class AgentServer {
220
222
  private clearApprovalTimer;
221
223
  /**
222
224
  * Resolve all of a chat session's pending approvals as cancelled and clear
223
- * the matching server-side approval timers. Used by handleCancel's
225
+ * any matching server-side approval timers. Used by handleCancel's
224
226
  * per-session path so a Stop while a tool is awaiting approval doesn't leave
225
- * the tool hanging until APPROVAL_TIMEOUT_MS (and leak the timer). Each
226
- * requestId in the session map has a same-keyed entry in this.approvalTimers
227
- * (see requestAskUserForSession / makeBrowserBridge).
227
+ * the tool hanging. Bounded request types have same-keyed timer entries;
228
+ * AskUserQuestion does not.
228
229
  */
229
230
  private cancelSessionApprovals;
230
231
  private clearAllApprovalTimers;