mixdog 0.9.145 → 0.9.146

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.145",
3
+ "version": "0.9.146",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -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
+ });
@@ -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,
@@ -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) {