mixdog 0.9.36 → 0.9.38

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.
Files changed (115) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -306,6 +306,26 @@ export function __saveModelSettingsForTest(cfgMod, route, options = {}) {
306
306
  return saveModelSettings(cfgMod, route, options);
307
307
  }
308
308
 
309
+ // Decide whether notifyFnForSession should mirror a terminal tool completion
310
+ // into the pending-message queue. Only the TUI execution-ui path actually
311
+ // injects the model-visible twin of the completion into the active loop, and it
312
+ // signals that with an EXPLICIT ack (modelVisibleDelivered) — never a generic
313
+ // truthy onNotification return. Skipping the mirror on a bare `handled===true`
314
+ // would let a display-only / API listener suppress the sole model-visible copy,
315
+ // so the model would never see the completion body. Mirror UNLESS the body was
316
+ // explicitly delivered; unknown/generic-handled → keep the mirror.
317
+ export function shouldMirrorCompletionToPendingQueue({
318
+ callerSessionId,
319
+ modelVisibleDelivered,
320
+ hasEnqueue,
321
+ text,
322
+ meta = {},
323
+ } = {}) {
324
+ if (!callerSessionId || !hasEnqueue) return false;
325
+ if (modelVisibleDelivered) return false;
326
+ return shouldPersistModelVisibleToolCompletion(text, meta);
327
+ }
328
+
309
329
  export async function createMixdogSessionRuntime({
310
330
  provider,
311
331
  model,
@@ -713,7 +733,7 @@ export async function createMixdogSessionRuntime({
713
733
  // available / installs on quit" nag while the background stage runs
714
734
  // silently. The wording lives in the notice surface (notification-plan):
715
735
  // this emit only carries meta.version. TUI maps meta.kind 'update-notice'
716
- // to a transcript notice, never a model-visible message; tone 'info' =
736
+ // to a transient notice, never a model-visible message; tone 'info' =
717
737
  // non-urgent, applies on the next launch.
718
738
  const announceReady = () => {
719
739
  emitRuntimeNotification('update ready', { kind: 'update-notice', version: ver, tone: 'info' });
@@ -742,7 +762,7 @@ export async function createMixdogSessionRuntime({
742
762
 
743
763
  function emitRuntimeNotification(content, meta = {}) {
744
764
  const text = String(content || '').trim();
745
- if (!text) return false;
765
+ if (!text) return { handled: false, modelVisibleDelivered: false };
746
766
  const event = { content: text, meta: meta && typeof meta === 'object' ? meta : {} };
747
767
  let handled = false;
748
768
  for (const listener of [...notificationListeners]) {
@@ -750,19 +770,34 @@ export async function createMixdogSessionRuntime({
750
770
  if (listener(event) === true) handled = true;
751
771
  } catch {}
752
772
  }
753
- return handled;
773
+ // EXPLICIT model-visible-delivery ack: only the TUI execution-ui path sets
774
+ // event.modelVisibleDelivered when it enqueues the model-visible completion
775
+ // body into the active loop. A generic display-only / API listener that
776
+ // returns true does NOT set it, so `handled` alone must never be read as
777
+ // "the model saw the body".
778
+ const modelVisibleDelivered = event.modelVisibleDelivered === true;
779
+ return { handled, modelVisibleDelivered };
754
780
  }
755
781
 
756
782
  function notifyFnForSession(callerSessionId) {
757
783
  return (text, meta = {}) => {
758
- const handledByRuntimeListener = emitRuntimeNotification(text, meta);
784
+ const { handled: handledByRuntimeListener, modelVisibleDelivered } = emitRuntimeNotification(text, meta);
759
785
  let enqueued = false;
760
786
  // TUI sessions consume raw execution notifications for UI/task cards via
761
787
  // onNotification, but those raw envelopes are internal-only in pending
762
- // drain. Always mirror terminal completions with a model-visible wrapper
763
- // while keeping the raw text for UI display.
764
- if (callerSessionId && typeof mgr.enqueuePendingMessage === 'function'
765
- && shouldPersistModelVisibleToolCompletion(text, meta)) {
788
+ // drain. The TUI execution-ui path injects the model-visible twin of a
789
+ // terminal completion into the active loop and acks with
790
+ // modelVisibleDelivered, so mirroring it into the pending queue would
791
+ // double-inject the same completion — skip only on that EXPLICIT ack. A
792
+ // generic display-only / API listener returning true is NOT an ack: the
793
+ // mirror stays as the sole model-visible delivery.
794
+ if (shouldMirrorCompletionToPendingQueue({
795
+ callerSessionId,
796
+ modelVisibleDelivered,
797
+ hasEnqueue: typeof mgr.enqueuePendingMessage === 'function',
798
+ text,
799
+ meta,
800
+ })) {
766
801
  try {
767
802
  const visible = modelVisibleToolCompletionMessage(text, meta);
768
803
  // Terminal completion (gated by shouldPersistModelVisibleToolCompletion)
@@ -973,7 +1008,7 @@ export async function createMixdogSessionRuntime({
973
1008
  const meta = params.meta && typeof params.meta === 'object' ? params.meta : {};
974
1009
  const content = channelNotificationModelContent(params);
975
1010
  if (!content) return;
976
- const handled = emitRuntimeNotification(content, meta);
1011
+ const { handled } = emitRuntimeNotification(content, meta);
977
1012
  if (!handled && session?.id && shouldMirrorChannelNotificationToPending(meta)) {
978
1013
  try { mgr.enqueuePendingMessage(session.id, content); } catch {}
979
1014
  }
@@ -1228,7 +1263,15 @@ export async function createMixdogSessionRuntime({
1228
1263
  if (!startupProviderCatalogRefreshStarted && !closeRequested) {
1229
1264
  startupProviderCatalogRefreshStarted = true;
1230
1265
  try {
1231
- reg.refreshProviderCatalogsOnStartup();
1266
+ void Promise.resolve(reg.refreshProviderCatalogsOnStartup())
1267
+ .then(() => {
1268
+ invalidateProviderCaches();
1269
+ warmProviderModelCache();
1270
+ bootProfile('provider-catalogs:refresh-ready');
1271
+ })
1272
+ .catch((error) => {
1273
+ bootProfile('provider-catalogs:refresh-failed', { error: error?.message || String(error) });
1274
+ });
1232
1275
  bootProfile('provider-catalogs:refresh-started');
1233
1276
  } catch (error) {
1234
1277
  bootProfile('provider-catalogs:refresh-failed', { error: error?.message || String(error) });
@@ -2102,6 +2145,9 @@ export async function createMixdogSessionRuntime({
2102
2145
  scheduleStatuslineUsageRefresh,
2103
2146
  invalidateContextStatusCache,
2104
2147
  invalidateProviderCaches,
2148
+ createCurrentSession,
2149
+ invalidatePreSessionToolSurface,
2150
+ pushTranscriptRebind,
2105
2151
  collectSearchProviderModels,
2106
2152
  });
2107
2153
  const workflowAgentsApi = createWorkflowAgentsApi({
@@ -122,7 +122,12 @@ export function createSettingsApi({
122
122
  // language catalog entry and the full language list for the picker UI.
123
123
  getProfile() {
124
124
  const config = getConfig();
125
- const profile = cfgMod.normalizeProfileConfig(config.profile);
125
+ // In-memory config is flat: `config.profile` is what the save path
126
+ // (buildAgentSaveBuilder) persists into the on-disk `agent.profile`
127
+ // slot. Fall back to a nested `agent.profile` only for any stray
128
+ // nested snapshot.
129
+ const stored = config?.profile ?? config?.agent?.profile;
130
+ const profile = cfgMod.normalizeProfileConfig(stored);
126
131
  return {
127
132
  ...profile,
128
133
  languageEntry: cfgMod.profileLanguageEntry(profile.language),
@@ -152,7 +157,7 @@ export function createSettingsApi({
152
157
  },
153
158
  setProfile(input = {}) {
154
159
  const config = getConfig();
155
- const current = cfgMod.normalizeProfileConfig(config.profile);
160
+ const current = cfgMod.normalizeProfileConfig(config?.profile ?? config?.agent?.profile);
156
161
  const next = { ...current };
157
162
  if (hasOwn(input, 'title') || hasOwn(input, 'name')) {
158
163
  next.title = input.title ?? input.name ?? '';
@@ -161,6 +166,10 @@ export function createSettingsApi({
161
166
  next.language = input.language ?? input.lang ?? 'system';
162
167
  }
163
168
  const normalized = cfgMod.normalizeProfileConfig(next);
169
+ // Persist flat: buildAgentSaveBuilder (config.mjs saveConfig) reads
170
+ // `config.profile` and writes it into the on-disk `agent.profile`
171
+ // section, which the prompt builder (readAgentConfig) reads. Writing a
172
+ // nested `agent.profile` here would be dropped by the save path.
164
173
  saveConfigAndAdopt({ ...config, profile: normalized });
165
174
  return this.getProfile();
166
175
  },
@@ -102,7 +102,13 @@ export function createWarmupSchedulers({
102
102
  scheduleProviderModelWarmup(backgroundBusyRetryMs);
103
103
  return;
104
104
  }
105
- warmProviderModelCache();
105
+ if (!isFirstTurnCompleted() && !envFlag('MIXDOG_PROVIDER_WARMUP_BEFORE_FIRST_TURN')) {
106
+ bootProfile('provider-models:warm-deferred', { reason: 'first-turn-pending' });
107
+ warmProviderModelCache();
108
+ scheduleProviderModelWarmup(backgroundBusyRetryMs);
109
+ return;
110
+ }
111
+ warmProviderModelCache({ loadSecrets: true });
106
112
  }, delayMs);
107
113
  timers.providerModelWarmupTimer.unref?.();
108
114
  }
@@ -14,6 +14,7 @@ import {
14
14
  import { presentErrorText, errorLine } from '../runtime/shared/err-text.mjs';
15
15
  import { updateJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
16
16
  import { normalizeAgentPermission } from '../runtime/shared/markdown-frontmatter.mjs';
17
+ import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listener-headroom.mjs';
17
18
  import { prepareAgentSession } from '../runtime/agent/orchestrator/agent-runtime/session-builder.mjs';
18
19
  import {
19
20
  abortAgentProgressWatchdog,
@@ -63,6 +64,8 @@ import { createNotify } from './agent-tool/notify.mjs';
63
64
  // identical public surface (`import { AGENT_TOOL } from './agent-tool.mjs'`).
64
65
  export { AGENT_TOOL };
65
66
 
67
+ ensureProcessListenerHeadroom(64);
68
+
66
69
  const STANDALONE_SOURCE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
67
70
 
68
71
  // Grace window during which a terminated/idle worker row is kept around so the
@@ -98,15 +101,18 @@ const DEFAULT_SPAWN_PREP_TIMEOUT_MS = envTimeoutMs('MIXDOG_AGENT_SPAWN_PREP_TIME
98
101
 
99
102
  // Global spawn-start stagger: unlimited-N parallel fan-out otherwise fires all
100
103
  // first provider calls in the same instant, racing the server-side prompt-
101
- // cache write/propagation window. Bench (parallel-10, identical-ms starts):
102
- // 12.5% cache miss; 3000ms lane stagger: 4.3%; 166ms: 6.0%. 1000ms default
103
- // picked as a low-cost middle ground. Chain (not a fixed lane count) so it
104
- // scales to any N: each new spawn's start is pushed to at least STAGGER_MS
105
- // after the previous spawn's start; sequential/non-overlapping spawns (now
106
- // already past the window) pay zero added latency. MIXDOG_SPAWN_STAGGER_MS=0
107
- // disables. Applied inside the deferred job body (see startDeferredSpawnJob)
108
- // so the agent tool call itself still returns task_id immediately.
109
- const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 1000);
104
+ // cache write/propagation window. Default 0 (off): mirrors the explore fan-out
105
+ // finding the first spawn's prompt-cache write only lands after its iter1
106
+ // completes (~seconds), so a sub-second stagger yields ~no cross-spawn cache
107
+ // reads while charging every later spawn the full delay, i.e. pure fan-out
108
+ // latency for negligible hit-rate gain. Kept as a knob for tuning: set
109
+ // MIXDOG_SPAWN_STAGGER_MS>0 to re-enable. When >0 it chains (not a fixed lane
110
+ // count) so it scales to any N: each new spawn's start is pushed to at least
111
+ // STAGGER_MS after the previous spawn's start; sequential/non-overlapping
112
+ // spawns pay zero added latency. Applied inside the deferred job body (see
113
+ // startDeferredSpawnJob) so the agent tool call itself still returns task_id
114
+ // immediately.
115
+ const SPAWN_STAGGER_MS = envTimeoutMs('MIXDOG_SPAWN_STAGGER_MS', 0);
110
116
  let lastSpawnStartAt = 0;
111
117
  async function waitForSpawnStagger() {
112
118
  if (SPAWN_STAGGER_MS <= 0) return;
@@ -5,6 +5,9 @@ import { makeAgentDispatch } from '../runtime/agent/orchestrator/agent-runtime/a
5
5
  import { loadConfig } from '../runtime/agent/orchestrator/config.mjs';
6
6
  import { initProviders } from '../runtime/agent/orchestrator/providers/registry.mjs';
7
7
  import { presentErrorText } from '../runtime/shared/err-text.mjs';
8
+ import { ensureProcessListenerHeadroom } from '../runtime/shared/process-listener-headroom.mjs';
9
+
10
+ ensureProcessListenerHeadroom(64);
8
11
 
9
12
  // Ported from the original mixdog tool-defs.mjs `explore` entry.
10
13
  // `aiWrapped` is dropped: in the standalone build there is no aiWrapped
@@ -89,7 +92,7 @@ function escapeXml(str) {
89
92
  // reminder. The full no-verdict contract lives at system level
90
93
  // (rules/agent/30-explorer.md).
91
94
  export function buildExplorerPrompt(query) {
92
- return `<query>${escapeXml(query)}</query>\nReminder: RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> answer NOW with those exact coordinates; zero -> one more batch if budget remains. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — never trace the chain, one anchor per concept. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path or pass only a path seen in an earlier result; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
95
+ return `<query>${escapeXml(query)}</query>\nReminder: BUDGET = TWO MESSAGES NORMALLY: message 1 = your multi-tool batch, message 2 = your answer text. A 3rd message (ANY extra tool call) is a DEFECT unless message 1 returned ZERO specific-token lines — extra code_graph/grep calls to confirm or upgrade an anchor you already hold are the single biggest source of overspend. TURN 1 IS ONE BATCH, NON-NEGOTIABLE: your FIRST tool message fires, in that single message together, grep (pattern[] packing 3-6 token variants) AND code_graph symbol_search (identifiers) AND find/glob (path/name fragments) — never emit grep alone and wait for its result before adding code_graph/find. Serial one-tool-per-turn is the #1 budget defect and forfeits the turn-1 -> answer path; a first turn that is not this multi-tool batch is malformed. After that batch returns, if ANY line carries a specific query token you MUST emit the ANSWER as your very next message: a further tool call to refine, confirm, or upgrade is the primary overspend defect — a second tool turn is legal ONLY when the batch returned ZERO specific-token lines. Then RULE ZERO is a binary gate after every tool result: >=1 path:line matching a SPECIFIC query token (product/library/domain name) -> STOP and answer NOW with those exact coordinates, this IS your final turn; zero -> one more batch if budget remains. Turns 2-3 exist SOLELY as zero-hit recovery (the previous turn matched ZERO specific tokens); spending a turn to confirm, refine, or upgrade an anchor you already hold is a defect. A generic-word-only match (schema, handler, config, resolver, index, error...) while the query's specific tokens match nowhere counts as ZERO, not a hit. There is no third branch: "hits exist but I want better ones" IS branch one — answer now. A code_graph symbol hit (find_symbol/symbol_search returning path:line) IS an anchor — emit it directly, never re-locate it with grep. Credibility is mechanical (specific-token match), never judged: "is this the real implementation / final handler / just a wrapper" are caller questions, FORBIDDEN here; mark weak anchors ? and answer anyway. Flow/how/trigger queries: first matching definition/entry anchors ARE the complete answer — trace no chains, one anchor per concept; the SOLE exception: a query that EXPLICITLY asks flow/default-resolution whose turn 1 yielded only an entry anchor (not the resolved value) may take ONE turn-2 hop to the resolving site, then stop. You locate WHERE, never WHY. Scope is ALWAYS the session working directory: omit path (project cwd default) or pass only a path seen in an earlier result; an unverified path/name fragment rides the SAME turn-1 find query[] batch (a find-only turn is a defect) and scopes grep/glob only through the exact path find returns; inventing a directory (/workspace/..., another repo's layout) is a defect — on zero hits change TOKENS, never guess paths. HARD max 3 tool turns; counter line (turn 1/3, turn 2/3, turn 3/3) on every tool message; expected shape is turn 1 -> answer, turns 2-3 are miss-recovery only (previous turn had ZERO matching lines), with changed tokens; after turn 3/3 you MUST answer best-so-far. Each turn = ONE maximal batch: a single grep whose pattern[] packs ALL token variants PLUS code_graph/find/glob in the SAME message; a single-tool turn is a wasted turn. Before emitting EXPLORATION_FAILED re-scan ALL earlier results: any line matching a specific query token -> answer with that anchor (? if weak) — a weak anchor beats a false miss; EXPLORATION_FAILED only when all 3 turns found zero token-matching lines. Output only anchor lines formatted as path:line — symbol/name — short reason, max 3 lines, or EXPLORATION_FAILED. For a CODE-location answer every line MUST carry :line (an explicit line number) — a bare filename with no :line is a DEFECT; with no line-anchored code evidence, return EXPLORATION_FAILED rather than a vague file-only or prose answer. EXCEPTION — file/dir-LOCATION queries (where does X store its config/logs/data on disk, which directory or file holds Y): an exact VERIFIED path (the file or directory itself, no :line) IS the valid answer — do not force a line number and do not FAIL. No preamble, bullets, numbering, headings, summary, code quotes, analysis, verdicts, ratings, or recommendations.`;
93
96
  }
94
97
 
95
98
  export function normalizeExploreQueries(rawQuery) {
package/src/tui/App.jsx CHANGED
@@ -1494,7 +1494,8 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1494
1494
  const n = Number(value || 0);
1495
1495
  const d = Number(total || 0);
1496
1496
  if (!d) return 'N/A';
1497
- return `${((n / d) * 100).toFixed(n > 0 && n < d / 100 ? 1 : 0)}%`;
1497
+ const p = Math.max(0, Math.min(100, (n / d) * 100));
1498
+ return `${p > 0 && p < 1 ? p.toFixed(1) : Math.floor(p)}%`;
1498
1499
  };
1499
1500
  const fmt = (value) => {
1500
1501
  const n = Number(value || 0);
@@ -2153,12 +2154,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2153
2154
  }
2154
2155
  }
2155
2156
  if (!commandText) return false;
2156
- if (state.commandBusy) {
2157
- store.pushNotice('wait for the current command to finish', 'warn');
2158
- return false;
2159
- }
2160
2157
 
2161
2158
  if (commandText.startsWith('/')) {
2159
+ if (state.commandBusy) {
2160
+ store.pushNotice('wait for the current command to finish', 'warn');
2161
+ return false;
2162
+ }
2162
2163
  const [cmd, ...rest] = commandText.slice(1).split(/\s+/);
2163
2164
  const accepted = runSlashCommand(cmd, rest.join(' ').trim());
2164
2165
  if (accepted !== false) clearPastedImagesSnapshot();
@@ -537,6 +537,7 @@ function computeTranscriptItemVariantKey(item) {
537
537
  const count = Number(item.count ?? 0);
538
538
  const completed = item.completedCount === undefined ? 'u' : Number(item.completedCount);
539
539
  const errors = item.errorCount === undefined ? 'u' : Number(item.errorCount);
540
+ const callErrors = item.callErrorCount === undefined ? 'u' : Number(item.callErrorCount);
540
541
  const isError = item.isError ? 1 : 0;
541
542
  const normalizedName = String(normalizeToolName(item.name) || '').toLowerCase();
542
543
  const aggregate = item.aggregate ? 1 : 0;
@@ -547,7 +548,7 @@ function computeTranscriptItemVariantKey(item) {
547
548
  const bgPrompt = textShapeFingerprint(bgArgs.prompt);
548
549
  const bgMessage = textShapeFingerprint(bgArgs.message);
549
550
  const bgError = textShapeFingerprint(bgArgs.error);
550
- return `x${expanded}:n${normalizedName}:g${aggregate}:r${resultShape}:R${rawShape}:c${count}:d${completed}:e${errors}:E${isError}:bt${bgType}:bs${bgStatus}:bk${bgTaskId}:bp${bgPrompt}:bm${bgMessage}:be${bgError}`;
551
+ return `x${expanded}:n${normalizedName}:g${aggregate}:r${resultShape}:R${rawShape}:c${count}:d${completed}:e${errors}:ce${callErrors}:E${isError}:bt${bgType}:bs${bgStatus}:bk${bgTaskId}:bp${bgPrompt}:bm${bgMessage}:be${bgError}`;
551
552
  }
552
553
  return `x${expanded}:s${textShapeFingerprint(item.text ?? item.result ?? '')}`;
553
554
  }
@@ -568,13 +569,43 @@ export const transcriptMeasuredRowsCache = new WeakMap();
568
569
  // (its final settled height is then captured by the normal WeakMap path).
569
570
  export const streamingMeasuredRowsById = new Map();
570
571
 
572
+ // High-water clamp for the STREAMING row ESTIMATE, keyed by stream item id.
573
+ // measureStreamingMarkdownRenderedRows (measure-rendered-rows.mjs) adds a +1
574
+ // gap row only while childCount===2 (stablePrefix box + unstableSuffix box).
575
+ // As the stable/unstable split moves per token — and when the suffix is
576
+ // momentarily whitespace-only, stablePrefix empties — childCount flips 1↔2
577
+ // frame-to-frame, so the estimate oscillates ±1. Streaming text only ever
578
+ // GROWS, so any per-frame DECREASE is spurious. Hold the max estimate seen this
579
+ // streaming run so streamingEstimateRows is NON-DECREASING, killing the -1 dip
580
+ // that shifts the transcript when a newline settles. Entry stores columns/
581
+ // toolExpanded so a real layout-basis change resets the water line (row count
582
+ // legitimately changes with width). Lifecycle mirrors streamingMeasuredRowsById
583
+ // exactly: pruned by pruneStreamingMeasuredRowsById and cleared at the same
584
+ // settle / invalidate delete sites in estimateTranscriptItemRowsCached.
585
+ const streamingEstimateHighWaterById = new Map();
586
+
587
+ /** True when either streaming id-keyed store holds entries, so the mount-prune
588
+ * call site fires even when only the estimate high-water map is populated (an
589
+ * item that never got a Yoga measurement but reached an estimate high-water,
590
+ * then was bulk-replaced, must still be pruned). */
591
+ export function hasStreamingRowStateToPrune() {
592
+ return streamingMeasuredRowsById.size > 0 || streamingEstimateHighWaterById.size > 0;
593
+ }
594
+
571
595
  /** Drop streamingMeasuredRowsById entries for ids no longer mounted, so the
572
596
  * store does not grow unbounded over a long session (mirrors the id→item /
573
597
  * id→callback map pruning already done for the mounted set). */
574
598
  export function pruneStreamingMeasuredRowsById(liveIds) {
575
- if (!liveIds || streamingMeasuredRowsById.size === 0) return;
576
- for (const id of streamingMeasuredRowsById.keys()) {
577
- if (!liveIds.has(id)) streamingMeasuredRowsById.delete(id);
599
+ if (!liveIds) return;
600
+ if (streamingMeasuredRowsById.size > 0) {
601
+ for (const id of streamingMeasuredRowsById.keys()) {
602
+ if (!liveIds.has(id)) streamingMeasuredRowsById.delete(id);
603
+ }
604
+ }
605
+ if (streamingEstimateHighWaterById.size > 0) {
606
+ for (const id of streamingEstimateHighWaterById.keys()) {
607
+ if (!liveIds.has(id)) streamingEstimateHighWaterById.delete(id);
608
+ }
578
609
  }
579
610
  }
580
611
 
@@ -627,7 +658,21 @@ export function streamingEstimateRows(item, columns, toolOutputExpanded) {
627
658
  const trimmedText = assistantTextForStreamingRowEstimate(item.text);
628
659
  const estimateItem = trimmedText === item.text ? item : { ...item, text: trimmedText };
629
660
  const raw = Math.max(1, Math.ceil(estimateTranscriptItemRows(estimateItem, columns, toolOutputExpanded)));
630
- return Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
661
+ const quantized = Math.ceil(raw / STREAMING_ROW_QUANTUM) * STREAMING_ROW_QUANTUM;
662
+ // High-water clamp: streaming text only grows, so never report fewer rows than
663
+ // this id already reached this run — absorbs the childCount 1↔2 gap flip that
664
+ // otherwise dips the estimate ±1 frame-to-frame. A columns/expanded change
665
+ // resets the water line (new width → legitimately new row count).
666
+ const id = item?.id;
667
+ if (id == null) return quantized;
668
+ const toolExpanded = toolOutputExpanded ? 1 : 0;
669
+ const prev = streamingEstimateHighWaterById.get(id);
670
+ if (!prev || prev.columns !== columns || prev.toolExpanded !== toolExpanded) {
671
+ streamingEstimateHighWaterById.set(id, { rows: quantized, columns, toolExpanded });
672
+ return quantized;
673
+ }
674
+ if (quantized > prev.rows) prev.rows = quantized;
675
+ return prev.rows;
631
676
  }
632
677
 
633
678
  // ── Same-frame streaming-tail growth compensation (scrolled-up only) ────────
@@ -705,15 +750,20 @@ export function estimateTranscriptItemRowsCached(item, columns, toolOutputExpand
705
750
  // estimate frame.
706
751
  return idEntry.rows;
707
752
  }
708
- if (idEntry) streamingMeasuredRowsById.delete(item.id);
753
+ if (idEntry) {
754
+ streamingMeasuredRowsById.delete(item.id);
755
+ streamingEstimateHighWaterById.delete(item.id);
756
+ }
709
757
  // No confirmed measurement yet for this id (item just started streaming):
710
758
  // nothing to defer against, so the first frame falls back to the estimate.
711
759
  return streamingEstimateRows(item, columns, toolOutputExpanded);
712
760
  }
713
- if (item.kind === 'assistant' && streamingMeasuredRowsById.has(item.id)) {
714
- // Item settled (no longer streaming): the id-keyed floor is no longer
715
- // relevant, the normal WeakMap-measured path now owns its height.
716
- streamingMeasuredRowsById.delete(item.id);
761
+ if (item.kind === 'assistant') {
762
+ // Item settled (no longer streaming): the id-keyed floor and the estimate
763
+ // high-water are no longer relevant the normal WeakMap-measured path now
764
+ // owns its height. Clear both so a later id reuse / this run cannot leak.
765
+ if (streamingMeasuredRowsById.has(item.id)) streamingMeasuredRowsById.delete(item.id);
766
+ if (streamingEstimateHighWaterById.has(item.id)) streamingEstimateHighWaterById.delete(item.id);
717
767
  }
718
768
  const variantKey = transcriptItemVariantKey(item);
719
769
  const toolExpanded = toolOutputExpanded ? 1 : 0;
@@ -15,6 +15,7 @@ import {
15
15
  transcriptMeasuredRowsCache,
16
16
  streamingMeasuredRowsById,
17
17
  pruneStreamingMeasuredRowsById,
18
+ hasStreamingRowStateToPrune,
18
19
  transcriptItemVariantKey,
19
20
  buildTranscriptRowIndexIncremental,
20
21
  transcriptStructureSignature,
@@ -632,7 +633,7 @@ export function useTranscriptWindow({
632
633
  if (!els.has(key)) refCache.delete(key);
633
634
  }
634
635
  }
635
- if (streamingMeasuredRowsById.size > 0) {
636
+ if (hasStreamingRowStateToPrune()) {
636
637
  pruneStreamingMeasuredRowsById(new Set(liveItems.keys()));
637
638
  }
638
639
  });
@@ -48,7 +48,10 @@ function percent(value, total) {
48
48
  function percentLabel(value, total) {
49
49
  const pct = percent(value, total);
50
50
  if (pct === null) return 'N/A';
51
- return `${pct > 0 && pct < 1 ? pct.toFixed(1) : Math.round(pct)}%`;
51
+ // Match the footer/statusline display: sub-1% keeps one decimal, otherwise
52
+ // show the floored percentage so /context and the statusline do not disagree
53
+ // by 1% around half-percent boundaries.
54
+ return `${pct > 0 && pct < 1 ? pct.toFixed(1) : Math.floor(pct)}%`;
52
55
  }
53
56
 
54
57
  function usageColor(pct) {
@@ -73,7 +73,6 @@ export function displayToolName(name, args) {
73
73
  }
74
74
 
75
75
  const TOOL_BLINK_MS = 500;
76
- const TOOL_BLINK_LIMIT_MS = 3000;
77
76
  const TOOL_PENDING_SHOW_DELAY_MS = 1000;
78
77
  // One shared-tick cadence covers both the 500ms blink and per-second elapsed;
79
78
  // finer than either boundary so both stay crisp off a single timer.
@@ -87,7 +86,7 @@ function statusCopy(name, label, count, doneCount, pending, isError, args = {})
87
86
  // dropping the pad just normalizes the spacing.
88
87
  return formatToolActionHeader(name, args, { pending, count });
89
88
  }
90
- export function ToolExecution({ name, args, result, rawResult, isError, errorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false }) {
89
+ export function ToolExecution({ name, args, result, rawResult, isError, errorCount, callErrorCount, expanded, columns = 80, attached = false, count = 1, completedCount = 0, startedAt = 0, completedAt = 0, aggregate = false, categories = {}, doneCategories = null, headerFinalized = true, deferredDisplayReady = false }) {
91
90
  const rowWidth = Math.max(1, Number(columns || 80));
92
91
  const groupCount = Math.max(1, Number(count || 1));
93
92
  const doneCount = Math.max(0, Math.min(groupCount, Number(completedCount || (result == null ? 0 : groupCount))));
@@ -116,13 +115,11 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
116
115
  // land — no empty band.
117
116
  const hasVisibleProgress = doneCount > 0 || Boolean(String(rt || '').trim());
118
117
  const pendingDisplayReady = !pending || !startedAtMs || pendingDelayElapsed || pendingAgeMs >= TOOL_PENDING_SHOW_DELAY_MS || hasVisibleProgress || deferredDisplayReady;
119
- // Derived blink (was two per-card setIntervals + a setTimeout): the dot blinks
120
- // at TOOL_BLINK_MS while a display-ready pending card is fresh, then goes solid
121
- // once TOOL_BLINK_LIMIT_MS elapses. Phase comes from Date.now() so the cadence
122
- // is identical to the old interval without owning a timer.
118
+ // Derived blink (was two per-card setIntervals + a setTimeout): while pending,
119
+ // the dot keeps blinking until the tool resolves. Phase comes from Date.now()
120
+ // so the cadence is identical to the old interval without owning a timer.
123
121
  const blinkActive = pending && pendingDisplayReady;
124
- const blinkExpired = blinkActive && startedAtMs > 0 && (nowMs - startedAtMs) >= TOOL_BLINK_LIMIT_MS;
125
- const blinkOn = !blinkActive || blinkExpired
122
+ const blinkOn = !blinkActive
126
123
  ? true
127
124
  : Math.floor(nowMs / TOOL_BLINK_MS) % 2 === 0;
128
125
  // Keep the action verb in its active form until the engine explicitly seals
@@ -135,6 +132,12 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
135
132
  const elapsedMs = startedAtMs ? Math.max(0, (pending ? nowMs : (completedAtMs || nowMs)) - startedAtMs) : 0;
136
133
  const elapsed = elapsedMs >= 1000 ? formatElapsed(elapsedMs) : '';
137
134
  const failedCount = clampFailureCount(errorCount, groupCount, isError);
135
+ // Real tool-call failures only (backend isError / error toolKind). Drives the
136
+ // ● dot color; command/result failures (shell exit, failed status) are counted
137
+ // in `failedCount`/L2 detail but never in `callFailedCount`, so they never
138
+ // paint the dot red. Fall back to 0 (never `isError`) when the engine did not
139
+ // supply a call-error count so a result failure can't leak into the dot.
140
+ const callFailedCount = clampFailureCount(callErrorCount, groupCount, false);
138
141
  const displayGroupCount = groupCount;
139
142
  const displayCategories = normalizeCountMap(categories || {});
140
143
  // In the DONE state, count only successful calls: error-terminated calls are
@@ -209,8 +212,8 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
209
212
  const aggregateTerminalStatus = pending
210
213
  ? 'running'
211
214
  : (resultTerminalStatus(rt) || (isError || failedCount > 0 ? 'failed' : 'completed'));
212
- const dotColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus: aggregateTerminalStatus });
213
- const dotText = pending && !blinkExpired && !blinkOn ? ' ' : TURN_MARKER;
215
+ const dotColor = toolStatusColor({ pending, groupCount, callFailedCount, terminalStatus: aggregateTerminalStatus });
216
+ const dotText = pending && !blinkOn ? ' ' : TURN_MARKER;
214
217
  const gutter = 2;
215
218
  const showHeaderExpandHint = hasRawResult;
216
219
  const hintLabel = `ctrl+o ${expanded ? 'collapse' : 'expand'}`;
@@ -423,7 +426,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
423
426
  const keepAgentDetail = (isError || agentFailureText) && !(agentHeaderFailure && !agentSurfaceBrief);
424
427
  visibleDetailLines = keepAgentDetail ? [agentDetailLine] : [];
425
428
  }
426
- const finalStatusColor = toolStatusColor({ pending, groupCount, failedCount, terminalStatus });
429
+ const finalStatusColor = toolStatusColor({ pending, groupCount, callFailedCount, terminalStatus });
427
430
  const dotColor = finalStatusColor;
428
431
  // Agent surface cards use directional markers: `←` for requests going OUT
429
432
  // (spawn/send/etc.) and `→` for the response coming back IN. Background
@@ -441,7 +444,7 @@ export function ToolExecution({ name, args, result, rawResult, isError, errorCou
441
444
  // `●` turn marker is a true 1-cell glyph and keeps the padding-only gutter.
442
445
  const isDirectionalMarker = isAgentResponse || isAgentSurfaceCard;
443
446
  const markerText = isDirectionalMarker ? `${markerGlyph} ` : markerGlyph;
444
- const dotText = pending && !blinkExpired && !blinkOn ? ' ' : markerText;
447
+ const dotText = pending && !blinkOn ? ' ' : markerText;
445
448
  let labelText;
446
449
  if (isAgentResponse) labelText = agentResponseTitle(parsedArgs);
447
450
  else if (isBackgroundResponse) labelText = backgroundTaskResultTitle(normalizedName, backgroundMeta || parsedArgs);
@@ -77,7 +77,7 @@ export const Item = React.memo(function Item({ item, prevKind, columns, toolOutp
77
77
  node = <ToolHookDenialCard item={item} columns={columns} />;
78
78
  break;
79
79
  }
80
- node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} doneCategories={item.doneCategories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} themeEpoch={themeEpoch} />;
80
+ node = <ToolExecution name={item.name} args={item.args} result={item.result} rawResult={item.rawResult} isError={item.isError} errorCount={item.errorCount} callErrorCount={item.callErrorCount} expanded={toolOutputExpanded || item.expanded} columns={columns} attached={false} count={item.count} completedCount={item.completedCount} startedAt={item.startedAt} completedAt={item.completedAt} aggregate={item.aggregate} categories={item.categories} doneCategories={item.doneCategories} headerFinalized={item.headerFinalized} deferredDisplayReady={item.deferredDisplayReady} />;
81
81
  break;
82
82
  }
83
83
  case 'notice':
@@ -390,16 +390,16 @@ export function clampFailureCount(errorCount, groupCount, isError) {
390
390
  // partial failure -> mixdogOrange || warning (some, not all, of the group failed)
391
391
  // all failed -> theme.error
392
392
  // cancelled -> theme.warning
393
- // Note: callers resolve terminalStatus to 'failed' whenever failedCount > 0
394
- // (they cannot tell partial from total failure), so a 'failed' status must
395
- // still fall through to the failedCount check below rather than short-circuit
396
- // to error otherwise a mixed-success batch (e.g. 1 of 3 failed) renders as
397
- // full-failure red instead of partial orange.
398
- export function toolStatusColor({ pending, groupCount, failedCount, terminalStatus = '' }) {
393
+ // The RED/orange failure color is driven ONLY by real tool-call errors
394
+ // (`callFailedCount` backend isError / error toolKind), NOT by command/result
395
+ // failures like a shell non-zero exit or a `[status: failed]` result. Those
396
+ // keep the card's L2 detail showing "Failed" but leave the dot on the success
397
+ // color. `terminalStatus` is still consulted so a cancelled card stays warning.
398
+ export function toolStatusColor({ pending, groupCount, callFailedCount = 0, terminalStatus = '' }) {
399
399
  if (pending) return theme.text;
400
400
  const status = normalizeTerminalStatus(terminalStatus);
401
401
  if (status === 'cancelled') return theme.warning;
402
- if (failedCount <= 0) return status === 'failed' ? theme.error : theme.success;
403
- if (groupCount > 1 && failedCount < groupCount) return theme.mixdogOrange || theme.warning;
402
+ if (callFailedCount <= 0) return theme.success;
403
+ if (groupCount > 1 && callFailedCount < groupCount) return theme.mixdogOrange || theme.warning;
404
404
  return theme.error;
405
405
  }