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.
- package/package.json +2 -2
- package/src/lib/rules-builder.cjs +12 -3
- package/src/output-styles/common.md +7 -9
- package/src/output-styles/simple.md +1 -1
- package/src/rules/shared/10-tool-workflow.md +18 -28
- package/src/rules/shared/40-editing.md +18 -6
- package/src/rules/shared/60-verification.md +0 -1
- package/src/rules/shared/70-delivery.md +4 -1
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +4 -1
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +132 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +82 -40
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +64 -29
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +24 -6
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +41 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +190 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +41 -28
- package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +9 -49
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +78 -30
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +79 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +22 -10
- package/src/runtime/agent/orchestrator/tools/builtin/shell-lossless-compact.mjs +30 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -1
- package/src/runtime/shared/edit-tool-dialect.mjs +8 -0
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/task-notification-envelope.mjs +14 -0
- package/src/session-runtime/lifecycle-api.mjs +44 -1
- package/src/session-runtime/runtime-core.mjs +1 -0
- package/src/session-runtime/session-lifecycle.test.mjs +23 -0
- package/src/session-runtime/tool-policy-refresh.mjs +4 -1
- package/src/session-runtime/tool-policy-surface.test.mjs +23 -8
- package/src/tui/session/context-state.mjs +17 -3
- package/src/tui/session/session-api-ext.mjs +6 -1
- package/src/tui/session/session-api.mjs +6 -1
- package/src/tui/session/tool-card-results.mjs +7 -0
- package/src/tui/session/tool-result-status.mjs +31 -1
- package/src/tui/session/tool-result-status.test.mjs +30 -0
- 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
|
|
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({
|
|
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
|
|
120
|
-
assert.match(full, /
|
|
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
|
|
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:
|
|
147
|
-
assert.match(full, /
|
|
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, /
|
|
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 = ({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
});
|
package/src/tui/session/turn.mjs
CHANGED
|
@@ -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({
|
|
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;
|