pi-advisor-flow 0.4.0 → 0.5.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.
package/src/config.ts CHANGED
@@ -66,6 +66,8 @@ export let advisorOutcomeLoggingRef = false;
66
66
  export let advisorUntrackedContentRef = false;
67
67
  export let advisorTrackedFileContentRef = false;
68
68
  export let advisorScoutEnabledRef = false;
69
+ export let showUsageDetailsRef = true;
70
+ export let showUsageFooterRef = false;
69
71
 
70
72
  export const setExecutorRef = (ref: string) => {
71
73
  executorRef = ref;
@@ -174,6 +176,12 @@ export const setAdvisorTrackedFileContentRef = (enabled: boolean) => {
174
176
  export const setAdvisorScoutEnabledRef = (enabled: boolean) => {
175
177
  advisorScoutEnabledRef = enabled;
176
178
  };
179
+ export const setShowUsageDetailsRef = (enabled: boolean) => {
180
+ showUsageDetailsRef = enabled;
181
+ };
182
+ export const setShowUsageFooterRef = (enabled: boolean) => {
183
+ showUsageFooterRef = enabled;
184
+ };
177
185
 
178
186
  /**
179
187
  * Returns the current live settings state. Use this at UI boundaries instead of
@@ -200,6 +208,8 @@ export const getAdvisorSettings = () => ({
200
208
  redactSecrets: advisorRedactSecretsRef,
201
209
  scoutEnabled: advisorScoutEnabledRef,
202
210
  sessionSummary: advisorSessionSummaryRef,
211
+ showUsageDetails: showUsageDetailsRef,
212
+ showUsageFooter: showUsageFooterRef,
203
213
  simpleMode: simpleModeRef,
204
214
  toolPolicies: { ...advisorToolPoliciesRef },
205
215
  toolResultMaxBytes: advisorToolResultMaxBytesRef,
@@ -253,6 +263,8 @@ export interface AdvisorConfig {
253
263
  executor?: string;
254
264
  executorEffort?: string;
255
265
  gateFailureMode?: GateFailureMode;
266
+ showUsageDetails?: boolean;
267
+ showUsageFooter?: boolean;
256
268
  simpleMode?: boolean;
257
269
  }
258
270
 
@@ -273,6 +285,7 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
273
285
  "advisorPlanGate",
274
286
  "advisorSessionSummary",
275
287
  "advisorScoutEnabled",
288
+ "showUsageDetails",
276
289
  "simpleMode",
277
290
  "alwaysOn",
278
291
  "advisorToolResultMaxBytes",
@@ -286,6 +299,7 @@ const CONFIG_KEYS = new Set<keyof AdvisorConfig>([
286
299
  "executor",
287
300
  "executorEffort",
288
301
  "gateFailureMode",
302
+ "showUsageFooter",
289
303
  ]);
290
304
  const BOOLEAN_CONFIG_KEYS = [
291
305
  "advisorPlanGate",
@@ -296,6 +310,8 @@ const BOOLEAN_CONFIG_KEYS = [
296
310
  "advisorAutoLoopGate",
297
311
  "advisorSessionSummary",
298
312
  "advisorScoutEnabled",
313
+ "showUsageDetails",
314
+ "showUsageFooter",
299
315
  "simpleMode",
300
316
  "alwaysOn",
301
317
  "advisorHerdrIntegration",
@@ -482,6 +498,8 @@ const resetDefaults = () => {
482
498
  advisorUntrackedContentRef = false;
483
499
  advisorTrackedFileContentRef = false;
484
500
  advisorScoutEnabledRef = false;
501
+ showUsageDetailsRef = true;
502
+ showUsageFooterRef = false;
485
503
  };
486
504
 
487
505
  const applyOptionalConfig = <Key extends keyof AdvisorConfig>(
@@ -549,6 +567,8 @@ const applyConfig = (config: AdvisorConfig) => {
549
567
  setAdvisorSessionSummaryRef
550
568
  );
551
569
  applyOptionalConfig(config, "advisorScoutEnabled", setAdvisorScoutEnabledRef);
570
+ applyOptionalConfig(config, "showUsageDetails", setShowUsageDetailsRef);
571
+ applyOptionalConfig(config, "showUsageFooter", setShowUsageFooterRef);
552
572
  applyOptionalConfig(config, "simpleMode", setSimpleModeRef);
553
573
  applyOptionalConfig(config, "alwaysOn", setAlwaysOnRef);
554
574
  applyOptionalConfig(config, "gateFailureMode", setAdvisorFailureModeRef);
@@ -716,6 +736,8 @@ export const saveConfig = (_ctx: ExtensionContext) => {
716
736
  advisorUntrackedContent: advisorUntrackedContentRef,
717
737
  alwaysOn: alwaysOnRef,
718
738
  gateFailureMode: advisorFailureModeRef,
739
+ showUsageDetails: showUsageDetailsRef,
740
+ showUsageFooter: showUsageFooterRef,
719
741
  simpleMode: simpleModeRef,
720
742
  };
721
743
  writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
@@ -56,6 +56,129 @@ export interface CollectedTextStream {
56
56
  usage?: unknown;
57
57
  }
58
58
 
59
+ export const ADVISOR_STREAM_UPDATE_INTERVAL_MS = 90;
60
+
61
+ export interface CoalescedUpdateResult {
62
+ error?: unknown;
63
+ failed: boolean;
64
+ }
65
+
66
+ export interface CoalescedUpdate<T> {
67
+ cancel: () => void;
68
+ flush: () => CoalescedUpdateResult;
69
+ update: (value: T) => void;
70
+ }
71
+
72
+ export interface CoalescedUpdateScheduler {
73
+ clearTimeout: (timer: ReturnType<typeof setTimeout>) => void;
74
+ now: () => number;
75
+ setTimeout: (
76
+ callback: () => void,
77
+ delay: number
78
+ ) => ReturnType<typeof setTimeout>;
79
+ }
80
+
81
+ /**
82
+ * Keep stream updates responsive without forwarding every provider delta to
83
+ * the UI. The first update in a burst is immediate; later updates are kept as
84
+ * the latest value and published at most once per interval. `flush()` closes
85
+ * the publisher and is intended for terminal success/error paths.
86
+ */
87
+ export const createCoalescedUpdate = <T>(
88
+ publish: (value: T) => void,
89
+ intervalMs = ADVISOR_STREAM_UPDATE_INTERVAL_MS,
90
+ scheduler: CoalescedUpdateScheduler = {
91
+ clearTimeout,
92
+ now: Date.now,
93
+ setTimeout: (callback, delay) => setTimeout(callback, delay),
94
+ }
95
+ ): CoalescedUpdate<T> => {
96
+ if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
97
+ throw new Error("Coalesced update interval must be positive and finite.");
98
+ }
99
+
100
+ let closed = false;
101
+ let hasPending = false;
102
+ let pending: T | undefined;
103
+ let lastPublishedAt: number | undefined;
104
+ let timer: ReturnType<typeof setTimeout> | undefined;
105
+ let publishError: unknown;
106
+ let publishFailed = false;
107
+
108
+ const clearTimer = () => {
109
+ if (timer !== undefined) {
110
+ scheduler.clearTimeout(timer);
111
+ timer = undefined;
112
+ }
113
+ };
114
+
115
+ const publishPending = () => {
116
+ timer = undefined;
117
+ if (!hasPending) {
118
+ return;
119
+ }
120
+ const value = pending as T;
121
+ pending = undefined;
122
+ hasPending = false;
123
+ lastPublishedAt = scheduler.now();
124
+ try {
125
+ publish(value);
126
+ } catch (error) {
127
+ publishFailed = true;
128
+ publishError = error;
129
+ closed = true;
130
+ clearTimer();
131
+ }
132
+ };
133
+
134
+ const schedule = () => {
135
+ const elapsed =
136
+ lastPublishedAt === undefined
137
+ ? intervalMs
138
+ : scheduler.now() - lastPublishedAt;
139
+ const delay = Math.max(0, intervalMs - elapsed);
140
+ if (delay === 0) {
141
+ publishPending();
142
+ return;
143
+ }
144
+ timer = scheduler.setTimeout(publishPending, delay);
145
+ timer.unref?.();
146
+ };
147
+
148
+ return {
149
+ cancel: () => {
150
+ closed = true;
151
+ clearTimer();
152
+ pending = undefined;
153
+ hasPending = false;
154
+ },
155
+ flush: () => {
156
+ if (!closed) {
157
+ closed = true;
158
+ clearTimer();
159
+ publishPending();
160
+ }
161
+ return { error: publishError, failed: publishFailed };
162
+ },
163
+ update: (value) => {
164
+ if (closed) {
165
+ return;
166
+ }
167
+ if (publishFailed) {
168
+ throw publishError;
169
+ }
170
+ pending = value;
171
+ hasPending = true;
172
+ if (timer === undefined) {
173
+ schedule();
174
+ }
175
+ if (publishFailed) {
176
+ throw publishError;
177
+ }
178
+ },
179
+ };
180
+ };
181
+
59
182
  export const collectTextStream = async (
60
183
  resolved: ResolvedConfiguredModel,
61
184
  options: CollectTextStreamOptions,
@@ -124,6 +124,7 @@ export class AdvisorSessionState {
124
124
  string,
125
125
  { advice: string; trigger: ConsultationTrigger }
126
126
  >();
127
+ readonly #pendingAdvice = new Set<string>();
127
128
  readonly #reportedAdvice = new Set<string>();
128
129
  #draftConsultations = 0;
129
130
  #outcomes = 0;
@@ -138,6 +139,7 @@ export class AdvisorSessionState {
138
139
  this.#loopInterventions = 0;
139
140
  this.#consumedCalls = 0;
140
141
  this.#issuedAdvice.clear();
142
+ this.#pendingAdvice.clear();
141
143
  this.#reportedAdvice.clear();
142
144
  this.#draftConsultations = 0;
143
145
  this.#outcomes = 0;
@@ -237,16 +239,38 @@ export class AdvisorSessionState {
237
239
  return true;
238
240
  }
239
241
 
240
- claimAdvice(id: string) {
241
- if (this.#reportedAdvice.has(id)) {
242
+ reserveAdvice(id: string) {
243
+ if (this.#reportedAdvice.has(id) || this.#pendingAdvice.has(id)) {
242
244
  return;
243
245
  }
244
246
  const advice = this.#issuedAdvice.get(id);
245
247
  if (!advice) {
246
248
  return;
247
249
  }
250
+ this.#pendingAdvice.add(id);
251
+ return advice;
252
+ }
253
+
254
+ commitAdvice(id: string) {
255
+ if (!this.#pendingAdvice.delete(id)) {
256
+ return false;
257
+ }
248
258
  this.#reportedAdvice.add(id);
249
259
  this.#outcomes += 1;
260
+ return true;
261
+ }
262
+
263
+ releaseAdvice(id: string) {
264
+ this.#pendingAdvice.delete(id);
265
+ }
266
+
267
+ /** Compatibility helper for synchronous callers that can commit immediately. */
268
+ claimAdvice(id: string) {
269
+ const advice = this.reserveAdvice(id);
270
+ if (!advice) {
271
+ return;
272
+ }
273
+ this.commitAdvice(id);
250
274
  return advice;
251
275
  }
252
276
 
package/src/tools.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  contextMaxCharsRef,
36
36
  executorRef,
37
37
  getAdvisorMaxCallsPerSession,
38
+ getAdvisorSettings,
38
39
  isSimpleMode,
39
40
  loadConfig,
40
41
  } from "./config.js";
@@ -57,7 +58,12 @@ import {
57
58
  herdrAdvisorBlock,
58
59
  notifyHerdrAdvisorFailure,
59
60
  } from "./herdr.js";
60
- import { collectTextStream, resolveConfiguredModel } from "./model-stream.js";
61
+ import {
62
+ ADVISOR_STREAM_UPDATE_INTERVAL_MS,
63
+ collectTextStream,
64
+ createCoalescedUpdate,
65
+ resolveConfiguredModel,
66
+ } from "./model-stream.js";
61
67
  import { ADOPTIONS, appendOutcome, VALIDATIONS } from "./outcomes.js";
62
68
  import { readProjectPreferences } from "./preferences.js";
63
69
  import {
@@ -712,7 +718,10 @@ const updateAdvisorUsageStatus = (
712
718
  session: AdvisorSessionState
713
719
  ) => {
714
720
  if (ctx.hasUI) {
715
- ctx.ui.setStatus("advisor-usage", session.usageStatus());
721
+ ctx.ui.setStatus(
722
+ "advisor-usage",
723
+ getAdvisorSettings().showUsageFooter ? session.usageStatus() : undefined
724
+ );
716
725
  }
717
726
  };
718
727
 
@@ -740,12 +749,15 @@ export const gateFailureEffectForMode = (
740
749
  : ("session-blocked" as const);
741
750
  };
742
751
 
743
- const gateDecisionEffect = (decision: GateDecision) => {
752
+ const gateDecisionEffect = (
753
+ decision: GateDecision,
754
+ failureMode: "block-session" | "block-tool" | "warn-and-continue"
755
+ ) => {
744
756
  if (decision === "proceed") {
745
757
  return "continued" as const;
746
758
  }
747
759
  return decision === "blocked"
748
- ? ("session-blocked" as const)
760
+ ? gateFailureEffectForMode(failureMode)
749
761
  : ("tool-blocked" as const);
750
762
  };
751
763
 
@@ -753,15 +765,42 @@ const failureEffect = (
753
765
  category: GateFailureCategory,
754
766
  message: string,
755
767
  ctx: ExtensionContext,
756
- session: AdvisorSessionState
768
+ session: AdvisorSessionState,
769
+ failureMode: "block-session" | "block-tool" | "warn-and-continue"
757
770
  ) => {
758
771
  const reason = `Advisor gate ${category}: ${message}`;
759
- notifyLocalFailure(ctx, message, advisorFailureModeRef === "block-session");
772
+ notifyLocalFailure(ctx, message, failureMode === "block-session");
760
773
  notifyHerdrAdvisorFailure("Advisor gate failure", reason);
761
- if (advisorFailureModeRef === "warn-and-continue") {
774
+ if (failureMode === "warn-and-continue") {
775
+ return { block: false, effect: "continued" as const, reason };
776
+ }
777
+ if (failureMode === "block-tool") {
778
+ return { block: true, effect: "tool-blocked" as const, reason };
779
+ }
780
+ session.block(reason);
781
+ herdrAdvisorBlock.set(reason);
782
+ if (advisorBlockOnBlockedRef) {
783
+ ctx.abort();
784
+ }
785
+ return { block: true, effect: "session-blocked" as const, reason };
786
+ };
787
+
788
+ const blockedDecisionEffect = (
789
+ reason: string,
790
+ ctx: ExtensionContext,
791
+ session: AdvisorSessionState,
792
+ failureMode: "block-session" | "block-tool" | "warn-and-continue"
793
+ ) => {
794
+ if (failureMode === "warn-and-continue") {
795
+ if (ctx.hasUI) {
796
+ ctx.ui.notify(
797
+ "Advisor gate returned blocked; continuing by configuration.",
798
+ "warning"
799
+ );
800
+ }
762
801
  return { block: false, effect: "continued" as const, reason };
763
802
  }
764
- if (advisorFailureModeRef === "block-tool") {
803
+ if (failureMode === "block-tool") {
765
804
  return { block: true, effect: "tool-blocked" as const, reason };
766
805
  }
767
806
  session.block(reason);
@@ -870,12 +909,14 @@ const handleAutomaticGate = async (
870
909
  return;
871
910
  }
872
911
  const reason = `Advisor loop gate: normalized signature for ${event.toolName} repeated ${advisorLoopThresholdRef} times without a materially different tool action.`;
912
+ const failureMode = advisorFailureModeRef;
873
913
  if (!session.canConsult(getAdvisorMaxCallsPerSession())) {
874
914
  const failure = failureEffect(
875
915
  "budget-exhausted",
876
916
  "Advisor gate call budget is exhausted.",
877
917
  ctx,
878
- session
918
+ session,
919
+ failureMode
879
920
  );
880
921
  return failure.block ? { block: true, reason: failure.reason } : undefined;
881
922
  }
@@ -913,7 +954,7 @@ const handleAutomaticGate = async (
913
954
  ensureGateCall();
914
955
  if (!result.ok) {
915
956
  session.recordInvocation({
916
- executionEffect: gateFailureEffectForMode(advisorFailureModeRef),
957
+ executionEffect: gateFailureEffectForMode(failureMode),
917
958
  failure: result.category,
918
959
  kind: "gate",
919
960
  model: advisorRef,
@@ -925,7 +966,8 @@ const handleAutomaticGate = async (
925
966
  result.category,
926
967
  result.message,
927
968
  ctx,
928
- session
969
+ session,
970
+ failureMode
929
971
  );
930
972
  sendAutomaticGateFailure(
931
973
  pi,
@@ -939,7 +981,7 @@ const handleAutomaticGate = async (
939
981
  session.recordInvocation({
940
982
  cost: advisorUsageCost(result.usage),
941
983
  decision: result.decision,
942
- executionEffect: gateDecisionEffect(result.decision),
984
+ executionEffect: gateDecisionEffect(result.decision, failureMode),
943
985
  kind: "gate",
944
986
  model: result.model,
945
987
  trigger: result.trigger,
@@ -953,11 +995,13 @@ const handleAutomaticGate = async (
953
995
  }
954
996
  const gateReason = `Advisor loop review: ${result.markdown}`;
955
997
  if (result.decision === "blocked") {
956
- session.block(gateReason);
957
- herdrAdvisorBlock.set(gateReason);
958
- if (advisorBlockOnBlockedRef) {
959
- ctx.abort();
960
- }
998
+ const effect = blockedDecisionEffect(
999
+ gateReason,
1000
+ ctx,
1001
+ session,
1002
+ failureMode
1003
+ );
1004
+ return effect.block ? { block: true, reason: effect.reason } : undefined;
961
1005
  }
962
1006
  return { block: true, reason: gateReason };
963
1007
  } finally {
@@ -1074,6 +1118,11 @@ export class ScoutStatusManager {
1074
1118
  readonly #active = new Set<symbol>();
1075
1119
  readonly #known = new Set<symbol>();
1076
1120
  readonly #retired = new Set<symbol>();
1121
+ private readonly showStatus: boolean;
1122
+
1123
+ constructor(showStatus = true) {
1124
+ this.showStatus = showStatus;
1125
+ }
1077
1126
 
1078
1127
  register(token: symbol) {
1079
1128
  if (!this.#retired.has(token)) {
@@ -1088,7 +1137,9 @@ export class ScoutStatusManager {
1088
1137
  this.#known.add(token);
1089
1138
  if (event.type === "call" || event.type === "chunk") {
1090
1139
  this.#active.add(token);
1091
- ctx.ui.setStatus("advisor-scout", "Scout curating…");
1140
+ if (this.showStatus) {
1141
+ ctx.ui.setStatus("advisor-scout", "Scout curating…");
1142
+ }
1092
1143
  return;
1093
1144
  }
1094
1145
  this.release(ctx, token);
@@ -1098,7 +1149,7 @@ export class ScoutStatusManager {
1098
1149
  this.#active.delete(token);
1099
1150
  this.#known.delete(token);
1100
1151
  this.#retired.add(token);
1101
- if (!ctx.hasUI) {
1152
+ if (!(ctx.hasUI && this.showStatus)) {
1102
1153
  return;
1103
1154
  }
1104
1155
  ctx.ui.setStatus(
@@ -1113,7 +1164,7 @@ export class ScoutStatusManager {
1113
1164
  }
1114
1165
  this.#known.clear();
1115
1166
  this.#active.clear();
1116
- if (ctx.hasUI) {
1167
+ if (ctx.hasUI && this.showStatus) {
1117
1168
  ctx.ui.setStatus("advisor-scout", undefined);
1118
1169
  }
1119
1170
  }
@@ -1154,9 +1205,11 @@ export const renderScoutDetails = (
1154
1205
  ` ${scout.model}${scout.selectedCount === undefined ? "" : ` · ${scout.selectedCount} kept / ${Math.max(0, (scout.availableCount ?? 0) - scout.selectedCount)} omitted`}${scout.latencyMs === undefined ? "" : ` · ${(scout.latencyMs / 1000).toFixed(1)}s`}`
1155
1206
  ),
1156
1207
  ];
1157
- const usage = formatAdvisorUsage(scout.usage);
1158
- if (usage) {
1159
- lines.push(theme.fg("dim", ` Usage: ${usage}`));
1208
+ if (getAdvisorSettings().showUsageDetails) {
1209
+ const usage = formatAdvisorUsage(scout.usage);
1210
+ if (usage) {
1211
+ lines.push(theme.fg("dim", ` Usage: ${usage}`));
1212
+ }
1160
1213
  }
1161
1214
  if (scout.fallbackReason) {
1162
1215
  lines.push(theme.fg("warning", ` ${scout.fallbackReason}`));
@@ -1278,9 +1331,11 @@ const renderFinalAdvisorResult = (
1278
1331
  if (details?.advisor) {
1279
1332
  lines.push(theme.fg("dim", ` ${details.advisor}`));
1280
1333
  }
1281
- const usage = formatAdvisorUsage(details?.usage);
1282
- if (usage) {
1283
- lines.push(theme.fg("dim", ` Usage: ${usage}`));
1334
+ if (getAdvisorSettings().showUsageDetails) {
1335
+ const usage = formatAdvisorUsage(details?.usage);
1336
+ if (usage) {
1337
+ lines.push(theme.fg("dim", ` Usage: ${usage}`));
1338
+ }
1284
1339
  }
1285
1340
  const attachments = [
1286
1341
  details?.draftBytes
@@ -1344,10 +1399,14 @@ export const registerAdvisorTool = (
1344
1399
  pi: ExtensionAPI,
1345
1400
  session: AdvisorSessionState = advisorSessionState,
1346
1401
  dependencies: {
1402
+ appendOutcome?: typeof appendOutcome;
1403
+ consult?: typeof consultAdvisor;
1347
1404
  runGate?: typeof runAdvisorGate;
1348
1405
  statusManager?: ScoutStatusManager;
1349
1406
  } = {}
1350
1407
  ) => {
1408
+ const appendAdvisorOutcome = dependencies.appendOutcome ?? appendOutcome;
1409
+ const requestAdvisor = dependencies.consult ?? consultAdvisor;
1351
1410
  const reservedCalls = new Set<string>();
1352
1411
  const scoutStatus = dependencies.statusManager ?? new ScoutStatusManager();
1353
1412
 
@@ -1394,9 +1453,11 @@ export const registerAdvisorTool = (
1394
1453
  if (details?.advisor) {
1395
1454
  box.addChild(new Text(theme.fg("dim", ` ${details.advisor}`), 0, 0));
1396
1455
  }
1397
- const usage = formatAdvisorUsage(details?.usage);
1398
- if (usage) {
1399
- box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
1456
+ if (getAdvisorSettings().showUsageDetails) {
1457
+ const usage = formatAdvisorUsage(details?.usage);
1458
+ if (usage) {
1459
+ box.addChild(new Text(theme.fg("dim", ` Usage: ${usage}`), 0, 0));
1460
+ }
1400
1461
  }
1401
1462
  if (details?.text) {
1402
1463
  box.addChild(
@@ -1505,7 +1566,6 @@ export const registerAdvisorTool = (
1505
1566
  pi.registerTool({
1506
1567
  description:
1507
1568
  "Consult the on-demand Advisor model for strategic guidance. Call with an empty object for a contextual review; attach an optional draft for concrete plan or completion review. If the Advisor explicitly names a missing file, you may make a sequential follow-up call with includeTrackedFiles when enabled and relevant.",
1508
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: usage and consent paths remain explicit at the tool boundary.
1509
1569
  async execute(_id, params, signal, onUpdate, ctx) {
1510
1570
  reservedCalls.delete(_id);
1511
1571
  if (
@@ -1524,13 +1584,24 @@ export const registerAdvisorTool = (
1524
1584
  }
1525
1585
  herdrAdvisorActivity.start();
1526
1586
  let scoutDetails: ScoutToolDetails | undefined;
1587
+ const coalescedUpdate = createCoalescedUpdate(
1588
+ (update: Parameters<NonNullable<typeof onUpdate>>[0]) =>
1589
+ onUpdate?.(update),
1590
+ ADVISOR_STREAM_UPDATE_INTERVAL_MS
1591
+ );
1592
+ const flushUpdate = () => {
1593
+ const result = coalescedUpdate.flush();
1594
+ if (result.failed) {
1595
+ throw result.error;
1596
+ }
1597
+ };
1527
1598
  try {
1528
- const result = await consultAdvisor(
1599
+ const result = await requestAdvisor(
1529
1600
  ctx,
1530
1601
  resolveAdvisorRequest(params.question),
1531
1602
  signal,
1532
1603
  (t, tx) =>
1533
- onUpdate?.({
1604
+ coalescedUpdate.update({
1534
1605
  content: [{ text: tx, type: "text" }],
1535
1606
  details: {
1536
1607
  advisor: advisorRef,
@@ -1548,7 +1619,7 @@ export const registerAdvisorTool = (
1548
1619
  params.includeTrackedFiles,
1549
1620
  (event) => {
1550
1621
  scoutDetails = scoutDetailsFromEvent(event, scoutDetails);
1551
- onUpdate?.({
1622
+ coalescedUpdate.update({
1552
1623
  content: [{ text: scoutDetails.text ?? "", type: "text" }],
1553
1624
  details: {
1554
1625
  advisor: advisorRef,
@@ -1559,6 +1630,7 @@ export const registerAdvisorTool = (
1559
1630
  },
1560
1631
  _id
1561
1632
  );
1633
+ flushUpdate();
1562
1634
  session.issueAdvice(
1563
1635
  result.adviceId,
1564
1636
  result.markdown,
@@ -1575,9 +1647,7 @@ export const registerAdvisorTool = (
1575
1647
  });
1576
1648
  const usage = snapshotAdvisorUsage(result.usage);
1577
1649
  const piUsage = advisorUsageForPi(result.usage);
1578
- if (ctx.hasUI) {
1579
- ctx.ui.setStatus("advisor-usage", session.usageStatus());
1580
- }
1650
+ updateAdvisorUsageStatus(ctx, session);
1581
1651
  return {
1582
1652
  content: [
1583
1653
  {
@@ -1601,6 +1671,10 @@ export const registerAdvisorTool = (
1601
1671
  ...(piUsage ? { usage: piUsage } : {}),
1602
1672
  };
1603
1673
  } catch (error) {
1674
+ // Publish the latest partial state before surfacing a provider or
1675
+ // execution error. A failure from the UI sink must not replace the
1676
+ // original error because this path is also used for provider failures.
1677
+ coalescedUpdate.flush();
1604
1678
  const message = error instanceof Error ? error.message : String(error);
1605
1679
  session.recordInvocation({
1606
1680
  executionEffect: "continued",
@@ -1609,13 +1683,12 @@ export const registerAdvisorTool = (
1609
1683
  model: advisorRef,
1610
1684
  trigger: "executor-requested",
1611
1685
  });
1612
- if (ctx.hasUI) {
1613
- ctx.ui.setStatus("advisor-usage", session.usageStatus());
1614
- }
1686
+ updateAdvisorUsageStatus(ctx, session);
1615
1687
  notifyLocalFailure(ctx, message);
1616
1688
  notifyHerdrAdvisorFailure("Advisor consultation failed", message);
1617
1689
  throw error;
1618
1690
  } finally {
1691
+ coalescedUpdate.cancel();
1619
1692
  herdrAdvisorActivity.finish();
1620
1693
  }
1621
1694
  },
@@ -1692,18 +1765,19 @@ export const registerAdvisorTool = (
1692
1765
  details: { recorded: false },
1693
1766
  };
1694
1767
  }
1695
- const advice = session.claimAdvice(params.adviceId);
1768
+ const advice = session.reserveAdvice(params.adviceId);
1696
1769
  if (!advice) {
1697
- throw new Error("Unknown or already recorded adviceId.");
1770
+ throw new Error("Unknown, already recorded, or pending adviceId.");
1698
1771
  }
1699
1772
  try {
1700
- await appendOutcome({
1773
+ await appendAdvisorOutcome({
1701
1774
  adoption: params.adoption as (typeof ADOPTIONS)[number],
1702
1775
  advice: advice.advice,
1703
1776
  trigger: advice.trigger,
1704
1777
  validationStatus:
1705
1778
  params.validationStatus as (typeof VALIDATIONS)[number],
1706
1779
  });
1780
+ session.commitAdvice(params.adviceId);
1707
1781
  return {
1708
1782
  content: [
1709
1783
  { text: "Advisor outcome recorded locally.", type: "text" },
@@ -1711,6 +1785,7 @@ export const registerAdvisorTool = (
1711
1785
  details: { recorded: true },
1712
1786
  };
1713
1787
  } catch {
1788
+ session.releaseAdvice(params.adviceId);
1714
1789
  if (ctx.hasUI) {
1715
1790
  ctx.ui.notify(
1716
1791
  "Advisor outcome could not be recorded locally.",