mixdog 0.9.145 → 0.9.147

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 (46) hide show
  1. package/package.json +2 -2
  2. package/src/lib/rules-builder.cjs +12 -3
  3. package/src/output-styles/common.md +7 -9
  4. package/src/output-styles/simple.md +1 -1
  5. package/src/rules/shared/10-tool-workflow.md +18 -28
  6. package/src/rules/shared/40-editing.md +18 -6
  7. package/src/rules/shared/60-verification.md +0 -1
  8. package/src/rules/shared/70-delivery.md +4 -1
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +4 -1
  10. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +11 -4
  11. package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +132 -0
  12. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +82 -40
  13. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +64 -29
  14. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +24 -6
  15. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +41 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +190 -0
  17. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +41 -28
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +9 -49
  19. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +78 -30
  20. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +12 -0
  21. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +8 -0
  22. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +9 -0
  23. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +79 -0
  25. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +10 -2
  26. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  27. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +22 -10
  28. package/src/runtime/agent/orchestrator/tools/builtin/shell-lossless-compact.mjs +30 -1
  29. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -1
  32. package/src/runtime/shared/edit-tool-dialect.mjs +8 -0
  33. package/src/runtime/shared/pristine-execution-contract.json +3 -0
  34. package/src/runtime/shared/task-notification-envelope.mjs +14 -0
  35. package/src/session-runtime/lifecycle-api.mjs +44 -1
  36. package/src/session-runtime/runtime-core.mjs +1 -0
  37. package/src/session-runtime/session-lifecycle.test.mjs +23 -0
  38. package/src/session-runtime/tool-policy-refresh.mjs +4 -1
  39. package/src/session-runtime/tool-policy-surface.test.mjs +23 -8
  40. package/src/tui/session/context-state.mjs +17 -3
  41. package/src/tui/session/session-api-ext.mjs +6 -1
  42. package/src/tui/session/session-api.mjs +6 -1
  43. package/src/tui/session/tool-card-results.mjs +7 -0
  44. package/src/tui/session/tool-result-status.mjs +31 -1
  45. package/src/tui/session/tool-result-status.test.mjs +30 -0
  46. package/src/tui/session/turn.mjs +12 -3
@@ -23,7 +23,7 @@ eof_line: "*** End of File" LF
23
23
  // Lark custom tool. The tiny JSON schema remains only for function-only
24
24
  // compatibility paths; runtime knobs stay off the model surface.
25
25
  const APPLY_PATCH_FREEFORM_DESCRIPTION =
26
- 'Edit files with one raw V4A patch; do not wrap it in JSON. Use one Add/Delete/Update File block per target path and multiple @@ hunks within one Update File block. Add File atomically creates the file and missing parent directories, failing without changes if the target already exists; call it directly without a prior read, list, or mkdir. Use exact lines already in context; never re-open the file to build context or to verify a successful patch. Multi-file patches commit valid files and report rejected files separately.';
26
+ 'Edit files with one raw V4A patch; do not wrap it in JSON. Use one Add/Delete/Update File block per target path and multiple @@ hunks within one Update File block. Add File atomically creates the file and missing parent directories, failing without changes if the target already exists; call it directly without a prior read, list, or mkdir. Use exact lines already in context; never re-open the file to build context or to verify a successful patch. Multi-file patches commit valid files and report rejected files separately. Replaces sed/awk and echo redirection.';
27
27
 
28
28
  const APPLY_PATCH_JSON_DESCRIPTION = 'Edit files with one complete V4A patch in `patch`.';
29
29
 
@@ -792,7 +792,7 @@ export function execShellCommand({
792
792
  jobId,
793
793
  backgroundTimeoutMs: adoptedTimeoutMs,
794
794
  backgroundMessage: jobId
795
- ? `${_verb}; still running. Completion is automatic; do not poll with task read/monitor unless the user explicitly requests polling or periodic progress.`
795
+ ? `${_verb}; still running. Completion is automatic; do not call task read/monitor to wait or check progress. Continue independent work or end the turn. Use task read only when the user explicitly asks for current status, and task monitor only when they explicitly ask for periodic progress.`
796
796
  : `${_verb}; still running — judge from the partial output whether waiting can finish in budget, or diagnose and pursue an alternative.`,
797
797
  }),
798
798
  );
@@ -13,6 +13,14 @@ export function modelEditToolName(modelName) {
13
13
  return isGptFamilyModel(modelName) ? 'apply_patch' : 'edit';
14
14
  }
15
15
 
16
+ /**
17
+ * The edit dialect this model never receives. Rules that name a concrete edit
18
+ * tool are gated on it, so a session only reads the dialect it can call.
19
+ */
20
+ export function unusedModelEditToolName(modelName) {
21
+ return modelEditToolName(modelName) === 'edit' ? 'apply_patch' : 'edit';
22
+ }
23
+
16
24
  export function filterModelEditTools(tools, modelName) {
17
25
  const selected = modelEditToolName(modelName);
18
26
  return (Array.isArray(tools) ? tools : []).filter((tool) => {
@@ -26,6 +26,9 @@
26
26
  "MIXDOG_STALL_FIRST_BYTE_ABORT_S",
27
27
  "MIXDOG_CACHE_MESSAGES_TTL",
28
28
  "MIXDOG_OAI_TRANSPORT",
29
+ "MIXDOG_OPENAI_OAUTH_WS_WARMUP",
30
+ "MIXDOG_OAI_CODEX_WIRE_PARITY",
31
+ "MIXDOG_OAI_WS_DUMP_DIR",
29
32
  "MIXDOG_SESSION_TRANSCRIPT_LOG",
30
33
  "MIXDOG_USAGE_LOG"
31
34
  ],
@@ -47,14 +47,28 @@ export function renderShellCompletionEnvelope({
47
47
  stderrPreview = null,
48
48
  mergeStderr = false,
49
49
  } = {}) {
50
+ // Verdict, not just liveness. `status` only says the task reached a terminal
51
+ // state, so a run that completed with a non-zero exit reads as "completed"
52
+ // and invites a success reading. Foreground shell results already separate
53
+ // the two; background completions state the same outcome explicitly.
54
+ const normalizedStatus = String(status || '').toLowerCase();
55
+ const outcome = normalizedStatus === 'completed'
56
+ ? (exitCode === 0 ? 'success' : (typeof exitCode === 'number' ? 'command-failed' : 'completed'))
57
+ : (normalizedStatus === 'failed'
58
+ ? 'not-completed'
59
+ : ((normalizedStatus === 'cancelled' || normalizedStatus === 'canceled') ? 'cancelled' : null));
50
60
  const header = [
51
61
  `[task_id: ${jobId}]`,
52
62
  `[status: ${status}]`,
53
63
  `[exit: ${exitCode === null ? 'n/a' : exitCode}]`,
64
+ outcome ? `[outcome: ${outcome}]` : null,
54
65
  elapsedMs !== null ? `[elapsed: ${elapsedMs} ms]` : null,
55
66
  command ? `[command: ${compactCommand(command)}]` : null,
56
67
  ].filter((l) => l !== null);
57
68
  const bodySections = [
69
+ outcome === 'command-failed'
70
+ ? '[completed: the shell ran this command; its non-zero exit code and output are command results, not a tool failure]'
71
+ : null,
58
72
  summary ? `Summary: ${summary}` : null,
59
73
  stdoutPreview ? `\n[stdout preview]\n${stdoutPreview}` : null,
60
74
  (mergeStderr !== true && stderrPreview) ? `\n[stderr preview]\n${stderrPreview}` : null,
@@ -32,6 +32,32 @@ function resolveResumeCwd(session, currentCwd) {
32
32
  return session?.cwd || currentCwd;
33
33
  }
34
34
 
35
+ function contextNumber(value) {
36
+ const number = Number(value);
37
+ return Number.isFinite(number) && number >= 0 ? number : null;
38
+ }
39
+
40
+ export function inheritanceContextFit(status) {
41
+ const context = status && typeof status === 'object' ? status : {};
42
+ const compaction = context.compaction && typeof context.compaction === 'object'
43
+ ? context.compaction
44
+ : {};
45
+ const used = contextNumber(
46
+ compaction.pressureTokens
47
+ ?? compaction.currentEstimatedTokens
48
+ ?? context.usedTokens
49
+ ?? context.currentEstimatedTokens,
50
+ );
51
+ const limit = contextNumber(compaction.triggerTokens ?? context.contextWindow);
52
+ const known = used !== null && limit !== null && limit > 0;
53
+ return {
54
+ known,
55
+ fits: !known || used < limit,
56
+ used: used ?? 0,
57
+ limit: limit ?? 0,
58
+ };
59
+ }
60
+
35
61
  // Session lifecycle surface: teardown (close/abort), resume/new, and the
36
62
  // resumable-session listing. Extracted verbatim from the runtime API object;
37
63
  // stateless helpers are imported directly and the runtime injects live
@@ -51,7 +77,7 @@ export function createLifecycleApi(deps) {
51
77
  withTeardownDeadline, closePatchRuntimeIfLoaded, closeNativeToolTransports,
52
78
  stopSelfUpdateBootCheck,
53
79
  createCurrentSession, refreshRouteEffort,
54
- invalidateContextStatusCache, invalidatePreSessionToolSurface,
80
+ computeContextStatus, invalidateContextStatusCache, invalidatePreSessionToolSurface,
55
81
  applyResolvedCwd, resolveRoute, applyDeferredToolSurface, getStandaloneTools,
56
82
  beginRoutePreparation, clearRoutePreparation,
57
83
  notificationListeners, clearRuntimeNotifications,
@@ -606,7 +632,24 @@ export function createLifecycleApi(deps) {
606
632
  if (!hasUserConversationMessage(carried)) {
607
633
  throw new Error('inheritFrom: the source session has no conversation to carry');
608
634
  }
635
+ const messageStart = target.messages.length;
609
636
  target.messages.push(...structuredClone(carried));
637
+ invalidateContextStatusCache();
638
+ try {
639
+ const fit = inheritanceContextFit(
640
+ typeof computeContextStatus === 'function' ? computeContextStatus() : null,
641
+ );
642
+ if (fit.known && !fit.fits) {
643
+ throw new Error(
644
+ `inheritFrom: the full conversation needs ${Math.ceil(fit.used)} tokens `
645
+ + `but the selected model allows ${Math.floor(fit.limit)} before compaction`,
646
+ );
647
+ }
648
+ } catch (error) {
649
+ target.messages.splice(messageStart);
650
+ invalidateContextStatusCache();
651
+ throw error;
652
+ }
610
653
  target.inheritedFromSessionId = source.id;
611
654
  target.updatedAt = Date.now();
612
655
  if (!clean(target.title) && clean(source.title)) target.title = source.title;
@@ -1458,6 +1458,7 @@ export async function createMixdogSessionRuntime({
1458
1458
  stopSelfUpdateBootCheck: () => selfUpdate.stopBootCheck(),
1459
1459
  createCurrentSession,
1460
1460
  refreshRouteEffort,
1461
+ computeContextStatus,
1461
1462
  invalidateContextStatusCache,
1462
1463
  invalidatePreSessionToolSurface,
1463
1464
  applyResolvedCwd,
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { test } from 'node:test';
3
3
  import { makeResolveRoute } from './config-helpers.mjs';
4
4
  import { resolveRouteContextState, resolveRouteEffortState } from './session-lifecycle.mjs';
5
+ import { inheritanceContextFit } from './lifecycle-api.mjs';
5
6
 
6
7
  test('cold route metadata preserves persisted effort and enabled Fast mode', () => {
7
8
  assert.deepEqual(resolveRouteEffortState({
@@ -100,3 +101,25 @@ test('route config treats a cleared context percentage as model-default intent',
100
101
  model: 'gpt-5.4',
101
102
  }).contextPercent, undefined);
102
103
  });
104
+
105
+ test('session inheritance uses the selected model compaction boundary as its fit guard', () => {
106
+ assert.deepEqual(inheritanceContextFit({
107
+ usedTokens: 80_000,
108
+ contextWindow: 200_000,
109
+ compaction: {
110
+ pressureTokens: 95_000,
111
+ triggerTokens: 100_000,
112
+ },
113
+ }), {
114
+ known: true,
115
+ fits: true,
116
+ used: 95_000,
117
+ limit: 100_000,
118
+ });
119
+ assert.equal(inheritanceContextFit({
120
+ compaction: {
121
+ pressureTokens: 100_000,
122
+ triggerTokens: 100_000,
123
+ },
124
+ }).fits, false);
125
+ });
@@ -8,6 +8,7 @@ import { applyDeferredToolSurface, filterDisallowedTools } from './tool-catalog.
8
8
  import { deferredSurfaceModeForLead } from './effort.mjs';
9
9
  import { applyInitialDeferredToolManifestToBp2, composeSystemPrompt } from '../runtime/agent/orchestrator/context/collect.mjs';
10
10
  import { _buildSharedRules, _buildLeadRules } from '../runtime/agent/orchestrator/session/manager/rules-cache.mjs';
11
+ import { unusedModelEditToolName } from '../runtime/shared/edit-tool-dialect.mjs';
11
12
 
12
13
  function toolNames(list) {
13
14
  return (Array.isArray(list) ? list : []).map((item) => (
@@ -103,7 +104,9 @@ export function createToolPolicyRefresh({
103
104
  applyInitialDeferredToolManifestToBp2(session, pool, { rebuild: true });
104
105
 
105
106
  const allowsAgents = workflow?.delegatesAgents !== false;
106
- const baseRules = _buildSharedRules({ omitTools: denied });
107
+ const baseRules = _buildSharedRules({
108
+ omitTools: [...denied, unusedModelEditToolName(getRoute()?.model)],
109
+ });
107
110
  const roleRules = _buildLeadRules({ includeLeadBrief: allowsAgents });
108
111
  let coreMemoryContext = '';
109
112
  if (memoryToolsEnabled()) {
@@ -103,6 +103,18 @@ test('shared tool rules omit disabled web search and memory routes', () => {
103
103
  assert.doesNotMatch(omitted, /# Research/);
104
104
  assert.doesNotMatch(omitted, /# Memory/);
105
105
  assert.match(omitted, /`find`/);
106
+
107
+ // Exactly one edit dialect ships per model, so the rules must never describe
108
+ // the tool that is absent from the surface.
109
+ const patchOnly = buildSharedToolContent({ PLUGIN_ROOT: pluginRoot, omitTools: ['edit'] });
110
+ assert.doesNotMatch(patchOnly, /`edit`/);
111
+ assert.match(patchOnly, /Placement: use exact unchanged context/i);
112
+ assert.match(patchOnly, /source-file edits stay with `apply_patch`/i);
113
+ const editOnly = buildSharedToolContent({ PLUGIN_ROOT: pluginRoot, omitTools: ['apply_patch'] });
114
+ assert.doesNotMatch(editOnly, /`apply_patch`/);
115
+ assert.doesNotMatch(editOnly, /Add File|Update File/);
116
+ assert.match(editOnly, /Placement: use an exact unique target string/i);
117
+ assert.match(editOnly, /source-file edits stay with `edit`\./i);
106
118
  });
107
119
 
108
120
  test('shared tool rules keep workflow and shell-boundary anchors', () => {
@@ -111,15 +123,15 @@ test('shared tool rules keep workflow and shell-boundary anchors', () => {
111
123
  const full = buildSharedToolContent({ PLUGIN_ROOT: join(process.cwd(), 'src') });
112
124
  assert.match(full, /Minimize tool turns through maximal useful parallelism/i);
113
125
  assert.match(full, /In each round, issue every necessary non-overlapping call whose inputs are\s+already known/i);
126
+ assert.match(full, /Determine the required outcome and its gaps/i);
127
+ assert.match(full, /gather only what is missing, act, then verify the affected facets/i);
114
128
  assert.match(full, /Investigate, build, and verify only what the requested outcome requires, at\s+the level it requires/i);
115
129
  assert.match(full, /A check runs at the strictness the task requires; never raise a tool's own\s+severity beyond it/i);
116
130
  assert.match(full, /Cost is counted in\s+rounds, not calls/i);
117
- assert.match(full, /Plan the fewest evidence-complete dependent\s+rounds first/i);
118
131
  assert.match(full, /defer a call only when its target or arguments require an\s+earlier result/i);
119
- assert.match(full, /Route each remaining evidence facet once to its primary owner, preferring the\s+operation that directly returns the evidence needed for the next decision/i);
120
- assert.match(full, /summary, overview, or enumeration is not a prerequisite when that operation's\s+complete inputs are already known/i);
132
+ assert.match(full, /Route each evidence facet once to its primary owner/i);
133
+ assert.match(full, /enumeration is not a prerequisite when that operation's/i);
121
134
  assert.match(full, /apply\s+one analysis to many targets as one parameterized call/i);
122
- assert.match(full, /use Execution when the information can only be produced by running a program\s+or observing runtime state/i);
123
135
  assert.match(full, /Evidence or artifacts available only through program execution, calculation,\s+data transformation, generated output, or unsupported-format decoding→`shell`/i);
124
136
  assert.match(full, /an already-open shell is never a routing reason/i);
125
137
  assert.match(full, /Route the missing evidence to its primary owner/i);
@@ -138,15 +150,18 @@ test('shared tool rules keep workflow and shell-boundary anchors', () => {
138
150
  assert.match(full, /If verification fails, collect all failures, leave Verification/i);
139
151
  assert.match(full, /A successful verification closes the task unless later changes affect it/i);
140
152
  assert.doesNotMatch(full, /affected failed checks once/i);
141
- assert.match(full, /Every Git operation→`git`; source-file edits stay with `edit`\/`apply_patch`/i);
153
+ assert.match(full, /Every Git operation→`git`; source-file edits stay with `edit`\./i);
154
+ assert.match(full, /Every Git operation→`git`; source-file edits stay with `apply_patch`\./i);
142
155
  assert.doesNotMatch(full, /Every repository mutation→`git`/i);
143
156
  assert.doesNotMatch(full, /always batch safely in parallel/i);
144
157
  assert.match(full, /A required new file is created directly: Add File is itself the atomic\s+absence check/i);
145
158
  assert.match(full, /Source: use exact current target text from any visible evidence/i);
146
- assert.match(full, /Placement: with `edit`, use an exact unique target string/i);
147
- assert.match(full, /with `apply_patch`, use exact unchanged context/i);
159
+ assert.match(full, /Placement: use an exact unique target string/i);
160
+ assert.match(full, /Placement: use exact unchanged context/i);
148
161
  assert.match(full, /Apply all determined changes in the fewest safe calls the active tool\s+supports/i);
149
- assert.match(full, /Batch scope: never split one file across concurrent edit calls/i);
162
+ assert.match(full, /One file, several changes: one Update File block carries every hunk/i);
163
+ assert.match(full, /One file, several changes: issue the calls together in one turn/i);
164
+ assert.match(full, /Defer only ambiguous or result-dependent changes/i);
150
165
  assert.match(full, /Commit, push, release, and deployment happen only on the user's explicit\s+request/i);
151
166
  assert.match(full, /past facts recorded in prior work or sessions→`recall`/i);
152
167
  assert.match(full, /Use judgment to decide whether a durable memory should be stored/i);
@@ -90,7 +90,11 @@ export function createContextState({ runtime, getState, updateState, getPendingS
90
90
  if (Object.keys(patch).length > 0) updateState(patch);
91
91
  }
92
92
 
93
- const syncContextStats = ({ allowEstimated = false, invalidateExact = false } = {}) => {
93
+ const syncContextStats = ({
94
+ allowEstimated = false,
95
+ invalidateExact = false,
96
+ compactedEstimateTokens = null,
97
+ } = {}) => {
94
98
  if (getPendingSessionReset()) return null;
95
99
  const ctx = runtime.contextStatus?.() || null;
96
100
  if (!ctx) return null;
@@ -113,14 +117,24 @@ export function createContextState({ runtime, getState, updateState, getPendingS
113
117
  updateState({ stats });
114
118
  return ctx;
115
119
  }
116
- const estimatedTokens = Math.max(0, Number(ctx.currentEstimatedTokens ?? ctx.usedTokens ?? 0));
120
+ // A compact completion already carries the estimate measured from the
121
+ // replacement transcript. Prefer it for this publication instead of
122
+ // letting a lagging contextStatus/provider snapshot repaint the deleted
123
+ // pre-compact prefix until the next request lands.
124
+ const compactedEstimate = Number(compactedEstimateTokens);
125
+ const hasCompactedEstimate = compactedEstimateTokens != null
126
+ && Number.isFinite(compactedEstimate)
127
+ && compactedEstimate > 0;
128
+ const estimatedTokens = hasCompactedEstimate
129
+ ? compactedEstimate
130
+ : Math.max(0, Number(ctx.currentEstimatedTokens ?? ctx.usedTokens ?? 0));
117
131
  const usedTokens = Math.max(0, Number(ctx.usedTokens ?? estimatedTokens ?? 0));
118
132
  const usedSource = String(ctx.usedSource || '').toLowerCase();
119
133
  // A successful pre-send auto-compact rewrites the transcript before the
120
134
  // next provider request can publish fresh usage. Never keep painting the
121
135
  // exact token count measured against the deleted pre-compact prefix during
122
136
  // that gap; publish the compacted transcript estimate immediately.
123
- const forceEstimated = invalidateExact && estimatedTokens > 0;
137
+ const forceEstimated = hasCompactedEstimate || (invalidateExact && estimatedTokens > 0);
124
138
  const shouldPublishEstimate = allowEstimated && (
125
139
  usedSource === 'estimated'
126
140
  || Number(ctx.currentEstimatedTokens) > 0
@@ -9,7 +9,7 @@ import { flushTuiSteeringPersist } from './tui-steering-persist.mjs';
9
9
  import { getVoiceStatus, toggleVoice } from '../lib/voice-setup.mjs';
10
10
  import { createSessionOAuthFlowRegistry } from './oauth-flows.mjs';
11
11
  import { aggregateToolCategoryEntries, aggregateDoneCategories, classifyToolCategory, formatAggregateDetail, summarizeToolResult, toolLoadingTargets } from '../../runtime/shared/tool-surface.mjs';
12
- import { aggregateBucketForCategory, aggregateRawResult, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
12
+ import { aggregateBucketForCategory, aggregateRawResult, aggregateToolMembers, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
13
13
  import {
14
14
  isInternalTranscriptDisplayText,
15
15
  isTranscriptCancelledStatusText,
@@ -142,6 +142,7 @@ function buildRestoredAggregateItem(members) {
142
142
  toolName: item.name,
143
143
  }, resultText);
144
144
  calls.push({
145
+ callId: item.id,
145
146
  name: item.name,
146
147
  args: item.args,
147
148
  category,
@@ -150,7 +151,10 @@ function buildRestoredAggregateItem(members) {
150
151
  isExitError,
151
152
  exitCode,
152
153
  resultText,
154
+ rawResultText: String(item.rawResult ?? item.result ?? ''),
153
155
  resolved: true,
156
+ startedAt: item.startedAt,
157
+ completedAt: item.completedAt,
154
158
  summary: !isCallError && resultText.trim()
155
159
  ? summarizeToolResult(item.name, item.args, resultText, false)
156
160
  : null,
@@ -194,6 +198,7 @@ function buildRestoredAggregateItem(members) {
194
198
  result: displayDetail,
195
199
  text: displayDetail,
196
200
  rawResult: rawResult || null,
201
+ toolMembers: aggregateToolMembers(calls),
197
202
  ...(latestUiDiff ? { uiDiff: latestUiDiff.uiDiff } : {}),
198
203
  expanded: false,
199
204
  headerFinalized: true,
@@ -690,7 +690,12 @@ export function createSessionApiA(bag) {
690
690
  // no spinner until the compact already finished).
691
691
  await new Promise((resolve) => setTimeout(resolve, 0));
692
692
  const result = await runtime.compact({ recoverAgent: true });
693
- syncContextStats({ allowEstimated: true });
693
+ const compactChanged = Boolean(result && !result.error && result.changed !== false);
694
+ syncContextStats({
695
+ allowEstimated: true,
696
+ invalidateExact: compactChanged,
697
+ compactedEstimateTokens: compactChanged ? result.afterTokens : null,
698
+ });
694
699
  set({ ...routeState(), stats: { ...getState().stats } });
695
700
  if (result) {
696
701
  if (!result.error && result.changed !== false) {
@@ -19,6 +19,7 @@ import {
19
19
  groupedToolResultText,
20
20
  aggregateRawResult,
21
21
  aggregateSummaries,
22
+ aggregateToolMembers,
22
23
  assignAggregateSummaryOrder,
23
24
  failureDetailText,
24
25
  toolCallOutcome,
@@ -104,7 +105,9 @@ export function createToolCardResults({
104
105
  callRec.isExitError = isExitError;
105
106
  callRec.exitCode = exitCode;
106
107
  callRec.resultText = text;
108
+ callRec.rawResultText = rawText;
107
109
  callRec.resolved = true;
110
+ callRec.completedAt = callRec.completedAt || Date.now();
108
111
  const allCalls = [...aggregate.calls.values()];
109
112
  const completed = allCalls.filter((r) => r.resolved).length;
110
113
  const errors = allCalls.filter((r) => r.isError).length;
@@ -138,6 +141,7 @@ export function createToolCardResults({
138
141
  count: allCalls.length,
139
142
  completedCount: visualCompleted,
140
143
  doneCategories: aggregateDoneCategories(allCalls),
144
+ toolMembers: aggregateToolMembers(allCalls),
141
145
  completedAt: Number(currentItem?.completedAt) || Date.now(),
142
146
  });
143
147
  card.done = true;
@@ -247,9 +251,11 @@ export function createToolCardResults({
247
251
  for (const rec of allCalls) {
248
252
  if (rec.resolved) continue;
249
253
  rec.resolved = true;
254
+ rec.completedAt = rec.completedAt || Date.now();
250
255
  if (!rec.completedEarly) {
251
256
  rec.isError = false;
252
257
  rec.resultText = rec.resultText || '';
258
+ rec.rawResultText = rec.rawResultText ?? rec.resultText;
253
259
  }
254
260
  }
255
261
  const completed = allCalls.filter((r) => r.resolved).length;
@@ -283,6 +289,7 @@ export function createToolCardResults({
283
289
  count: allCalls.length,
284
290
  completedCount: totalCompleted,
285
291
  doneCategories: aggregateDoneCategories(allCalls),
292
+ toolMembers: aggregateToolMembers(allCalls),
286
293
  completedAt: Date.now(),
287
294
  });
288
295
  for (const sibling of toolCards || []) {
@@ -152,7 +152,7 @@ export function aggregateRawResult(calls) {
152
152
  const chunks = [];
153
153
  for (const rec of calls || []) {
154
154
  if (rec?.resolved !== true) continue;
155
- let text = String(rec?.resultText || '').replace(/\s+$/, '');
155
+ let text = String(rec?.rawResultText ?? rec?.resultText ?? '').replace(/\s+$/, '');
156
156
  if (!text.trim()) continue;
157
157
  const label = String(rec?.name || rec?.category || 'tool').trim() || 'tool';
158
158
  chunks.push(`${chunks.length + 1}. ${label}\n${text}`);
@@ -160,6 +160,36 @@ export function aggregateRawResult(calls) {
160
160
  return chunks.join('\n\n');
161
161
  }
162
162
 
163
+ /** Preserve the atomic calls behind a visual aggregate. Renderers can keep the
164
+ * aggregate as one quiet summary row while revealing the original tool names,
165
+ * arguments, and outputs in provider order. */
166
+ export function aggregateToolMembers(calls) {
167
+ const source = calls?.values?.() || calls || [];
168
+ const members = [];
169
+ for (const rec of source) {
170
+ if (!rec || typeof rec !== 'object') continue;
171
+ const completed = rec.resolved === true || rec.completedEarly === true;
172
+ members.push({
173
+ kind: 'tool',
174
+ ...(rec.callId != null ? { id: rec.callId } : {}),
175
+ name: String(rec.name || 'tool'),
176
+ args: rec.args ?? {},
177
+ result: rec.resultText ?? null,
178
+ rawResult: rec.rawResultText ?? rec.resultText ?? null,
179
+ isError: rec.isError === true,
180
+ errorCount: rec.isError === true ? 1 : 0,
181
+ callErrorCount: rec.isCallError === true ? 1 : 0,
182
+ exitErrorCount: rec.isExitError === true ? 1 : 0,
183
+ count: 1,
184
+ completedCount: completed ? 1 : 0,
185
+ headerFinalized: completed,
186
+ ...(Number(rec.startedAt) > 0 ? { startedAt: Number(rec.startedAt) } : {}),
187
+ ...(Number(rec.completedAt) > 0 ? { completedAt: Number(rec.completedAt) } : {}),
188
+ });
189
+ }
190
+ return members;
191
+ }
192
+
163
193
  export function aggregateBucketForCategory(category, { agentBatch = '' } = {}) {
164
194
  // Merge consecutive tool calls of the SAME category into one aggregate card;
165
195
  // a different category opens a fresh card (no cross-category merge). The
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
3
 
4
4
  import {
5
+ aggregateToolMembers,
5
6
  failureDetailText,
6
7
  shellCommandExitCode,
7
8
  toolCallOutcome,
@@ -99,3 +100,32 @@ test('shared TUI/desktop tone keeps command failures warning and tool failures r
99
100
  assert.equal(deriveToolOutcomeTone({ terminalStatus: 'exit', exitFailedCount: 1 }), 'warning');
100
101
  assert.equal(deriveToolOutcomeTone({ terminalStatus: 'failed', callFailedCount: 1 }), 'error');
101
102
  });
103
+
104
+ test('aggregate members preserve atomic tool identity, inputs, outputs, and order', () => {
105
+ const members = aggregateToolMembers([
106
+ {
107
+ callId: 'call-read',
108
+ name: 'read',
109
+ args: { file_path: 'a.ts' },
110
+ resultText: 'source',
111
+ resolved: true,
112
+ isError: false,
113
+ },
114
+ {
115
+ callId: 'call-shell',
116
+ name: 'shell',
117
+ args: { command: 'exit 2' },
118
+ resultText: 'boom',
119
+ rawResultText: '[exit code: 2]\nboom',
120
+ resolved: true,
121
+ isError: false,
122
+ isExitError: true,
123
+ },
124
+ ]);
125
+ assert.deepEqual(members.map(({ id, name, args, result, rawResult, exitErrorCount }) => ({
126
+ id, name, args, result, rawResult, exitErrorCount,
127
+ })), [
128
+ { id: 'call-read', name: 'read', args: { file_path: 'a.ts' }, result: 'source', rawResult: 'source', exitErrorCount: 0 },
129
+ { id: 'call-shell', name: 'shell', args: { command: 'exit 2' }, result: 'boom', rawResult: '[exit code: 2]\nboom', exitErrorCount: 1 },
130
+ ]);
131
+ });
@@ -9,7 +9,7 @@ import { isCancelLikeError } from '../../runtime/shared/err-text.mjs';
9
9
  import { toolCallId, toolResultCallId, toolCallName, toolCallArgs } from './tool-call-fields.mjs';
10
10
  import { promptDisplayText, STEERING_SUPPRESSED_DISPLAY } from './queue-helpers.mjs';
11
11
  import { TUI_FRAME_MS, yieldToRenderer } from './render-timing.mjs';
12
- import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, assignAggregateSummaryOrder, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
12
+ import { aggregateRawResult, aggregateBucketForCategory, aggregateSummaries, aggregateToolMembers, assignAggregateSummaryOrder, failureDetailText, toolCallOutcome } from './tool-result-status.mjs';
13
13
 
14
14
  export const STREAM_BATCH_INTERVAL_MS = TUI_FRAME_MS;
15
15
 
@@ -327,6 +327,7 @@ export function createRunTurn(bag) {
327
327
  result: displayDetail,
328
328
  text: displayDetail,
329
329
  rawResult: rawResult || null,
330
+ toolMembers: aggregateToolMembers(allCalls),
330
331
  isError: errors > 0,
331
332
  errorCount: errors,
332
333
  callErrorCount: callErrors,
@@ -403,6 +404,7 @@ export function createRunTurn(bag) {
403
404
  count: aggregate.calls.size,
404
405
  completedCount: [...aggregate.calls.values()].filter((r) => r.resolved || r.completedEarly).length,
405
406
  categories: Object.fromEntries(aggregate.categories),
407
+ toolMembers: aggregateToolMembers(aggregate.calls),
406
408
  };
407
409
  if (aggregate.pushed) {
408
410
  patchItem(aggregate.itemId, patch);
@@ -678,7 +680,9 @@ export function createRunTurn(bag) {
678
680
  callRec.isExitError = isExitError;
679
681
  callRec.exitCode = exitCode;
680
682
  callRec.resultText = text;
683
+ callRec.rawResultText = rawText;
681
684
  callRec.completedEarly = true;
685
+ callRec.completedAt = callRec.completedAt || Date.now();
682
686
  const allCalls = [...aggregate.calls.values()];
683
687
  const completedCount = allCalls.filter((r) => r.resolved || r.completedEarly).length;
684
688
  const errors = allCalls.filter((r) => r.isError).length;
@@ -710,6 +714,7 @@ export function createRunTurn(bag) {
710
714
  exitErrorCount: exitErrors,
711
715
  count: allCalls.length,
712
716
  completedCount: visualCompleted,
717
+ toolMembers: aggregateToolMembers(allCalls),
713
718
  };
714
719
  if (visualCompleted >= allCalls.length) {
715
720
  patch.completedAt = Number(currentItem?.completedAt) || Date.now();
@@ -889,7 +894,7 @@ export function createRunTurn(bag) {
889
894
  count: Number(prevCategory?.count || 0) + Number(categoryEntry.count || 1),
890
895
  });
891
896
  }
892
- aggregateCard.calls.set(callKey, { name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, isExitError: false, exitCode: null, resultText: null, resolved: false, completedEarly: false });
897
+ aggregateCard.calls.set(callKey, { callId: callKey, name, args, category, summary: null, summarySeq: null, isError: false, isCallError: false, isExitError: false, exitCode: null, resultText: null, rawResultText: null, resolved: false, completedEarly: false, startedAt: Date.now(), completedAt: null });
893
898
  touchedAggregates.add(aggregateCard);
894
899
  const card = { itemId: aggregateCard.itemId, callId: callKey, done: false, aggregate: aggregateCard };
895
900
  if (callId) {
@@ -983,7 +988,11 @@ export function createRunTurn(bag) {
983
988
  // Compaction itself remains owned by the pre-provider-send pass.
984
989
  // This event only refreshes the gauge from the already-mutated
985
990
  // transcript before another render can show stale pressure.
986
- syncContextStats({ allowEstimated: true, invalidateExact: compactChanged });
991
+ syncContextStats({
992
+ allowEstimated: true,
993
+ invalidateExact: compactChanged,
994
+ compactedEstimateTokens: compactChanged ? event?.afterTokens : null,
995
+ });
987
996
  },
988
997
  onStageChange: async (stage, detail = null) => {
989
998
  if (!markTurnProgress(`stage:${String(stage || '')}`)) return;