mixdog 0.9.55 → 0.9.58
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 +3 -1
- package/scripts/_smoke_wd.mjs +7 -0
- package/scripts/agent-terminal-reap-test.mjs +127 -2
- package/scripts/compact-file-reattach-test.mjs +88 -0
- package/scripts/compact-pressure-test.mjs +45 -21
- package/scripts/compact-recall-digest-test.mjs +57 -0
- package/scripts/compact-smoke.mjs +35 -39
- package/scripts/desktop-session-bridge-test.mjs +271 -9
- package/scripts/generate-oc-icons.mjs +11 -0
- package/scripts/live-share-test.mjs +148 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/max-output-recovery-test.mjs +12 -1
- package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
- package/scripts/provider-admission-scheduler-test.mjs +100 -0
- package/scripts/provider-contract-test.mjs +49 -0
- package/scripts/resource-admission-test.mjs +5 -2
- package/scripts/session-ingest-compaction-smoke.mjs +38 -0
- package/scripts/steering-drain-buckets-test.mjs +15 -0
- package/scripts/submit-commandbusy-race-test.mjs +54 -3
- package/scripts/tool-smoke.mjs +2 -3
- package/scripts/tui-ambiguous-width-test.mjs +113 -0
- package/scripts/tui-runtime-load-bench.mjs +5 -0
- package/scripts/tui-transcript-perf-test.mjs +167 -0
- package/src/app.mjs +23 -0
- package/src/cli.mjs +6 -0
- package/src/lib/keychain-cjs.cjs +70 -20
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
- package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
- package/src/runtime/channels/lib/scheduler.mjs +8 -6
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
- package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
- package/src/runtime/channels/lib/whisper-language.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
- package/src/runtime/memory/lib/query-handlers.mjs +10 -4
- package/src/runtime/memory/lib/recall-format.mjs +43 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
- package/src/runtime/shared/err-text.mjs +4 -1
- package/src/runtime/shared/resource-admission.mjs +11 -0
- package/src/runtime/shared/schedule-model-ref.mjs +28 -0
- package/src/runtime/shared/schedule-session-run.mjs +63 -0
- package/src/runtime/shared/schedules-db.mjs +8 -3
- package/src/runtime/shared/tool-result-summary.mjs +4 -1
- package/src/runtime/shared/tool-surface.mjs +7 -7
- package/src/runtime/shared/transcript-writer.mjs +17 -0
- package/src/session-runtime/channel-config-api.mjs +11 -0
- package/src/session-runtime/config-helpers.mjs +9 -6
- package/src/session-runtime/context-status.mjs +80 -3
- package/src/session-runtime/lifecycle-api.mjs +154 -40
- package/src/session-runtime/provider-auth-api.mjs +21 -2
- package/src/session-runtime/provider-usage.mjs +4 -1
- package/src/session-runtime/resource-api.mjs +12 -11
- package/src/session-runtime/runtime-core.mjs +45 -11
- package/src/session-runtime/session-text.mjs +56 -6
- package/src/session-runtime/session-turn-api.mjs +70 -2
- package/src/session-runtime/tool-catalog.mjs +2 -1
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/render.mjs +5 -1
- package/src/standalone/agent-tool.mjs +63 -3
- package/src/standalone/channel-admin.mjs +19 -3
- package/src/standalone/channel-daemon.mjs +40 -0
- package/src/tui/app/resume-picker.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +19 -0
- package/src/tui/app/transcript-window.mjs +45 -8
- package/src/tui/app/use-mouse-input.mjs +101 -31
- package/src/tui/app/use-transcript-window.mjs +53 -9
- package/src/tui/components/ToolExecution.jsx +3 -4
- package/src/tui/components/TranscriptItem.jsx +3 -0
- package/src/tui/dist/index.mjs +17391 -14920
- package/src/tui/engine/context-state.mjs +10 -1
- package/src/tui/engine/live-share.mjs +390 -0
- package/src/tui/engine/queue-helpers.mjs +23 -0
- package/src/tui/engine/session-api-ext.mjs +439 -40
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +21 -7
- package/src/tui/engine/tool-card-results.mjs +3 -1
- package/src/tui/engine/turn.mjs +57 -4
- package/src/tui/engine.mjs +211 -7
- package/src/tui/index.jsx +2 -0
- package/src/tui/lib/voice-setup.mjs +10 -4
- package/src/tui/paste-attachments.mjs +4 -1
- package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +3 -0
- package/vendor/ink/build/display-width.js +14 -0
- package/vendor/ink/build/output.js +24 -3
- package/vendor/ink/build/wrap-text.d.ts +2 -0
- package/vendor/ink/build/wrap-text.js +45 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.58",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
"test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs",
|
|
71
71
|
"test:tui-input-render": "node --test scripts/prompt-immediate-render-test.mjs",
|
|
72
72
|
"test:tui-streaming-window": "node --test scripts/streaming-tail-window-test.mjs",
|
|
73
|
+
"test:tui-ambiguous-width": "node --test scripts/tui-ambiguous-width-test.mjs",
|
|
73
74
|
"test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --test scripts/verify-release-assets-test.mjs",
|
|
74
75
|
"test:release-focused": "npm run test:release-assets && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && node --test scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && node --test scripts/windows-hide-spawn-options-test.mjs && node --test scripts/tui-transcript-perf-test.mjs",
|
|
75
76
|
"test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
|
|
@@ -117,6 +118,7 @@
|
|
|
117
118
|
"react": "^19.2.7",
|
|
118
119
|
"string-width": "^8.2.1",
|
|
119
120
|
"strip-ansi": "^7.2.0",
|
|
121
|
+
"tiktoken": "^1.0.22",
|
|
120
122
|
"undici": "^8.5.0",
|
|
121
123
|
"wrap-ansi": "^10.0.0",
|
|
122
124
|
"ws": "^8.21.0",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const wd = setTimeout(() => {
|
|
2
|
+
console.error('=== WATCHDOG 45s: active resources:', JSON.stringify(process.getActiveResourcesInfo()));
|
|
3
|
+
process.exit(99);
|
|
4
|
+
}, 45000);
|
|
5
|
+
await import('./compact-smoke.mjs');
|
|
6
|
+
clearTimeout(wd);
|
|
7
|
+
console.error('=== script import completed normally');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import {
|
|
@@ -41,7 +41,12 @@ try {
|
|
|
41
41
|
writeFileSync(join(root, 'mixdog-config.json'), JSON.stringify({
|
|
42
42
|
agent: { autoClear: overrideConfig.autoClear },
|
|
43
43
|
}));
|
|
44
|
-
const {
|
|
44
|
+
const {
|
|
45
|
+
deleteSession,
|
|
46
|
+
markSessionClosed,
|
|
47
|
+
saveSession,
|
|
48
|
+
sweepStaleSessions,
|
|
49
|
+
} = await import('../src/runtime/agent/orchestrator/session/store.mjs');
|
|
45
50
|
const old = Date.now() - 181_000;
|
|
46
51
|
const known = {
|
|
47
52
|
id: 'sess_known_reap',
|
|
@@ -83,6 +88,126 @@ try {
|
|
|
83
88
|
assert.ok(defaultSweep.details.some((detail) => detail.id === known.id), 'store reaps a listed provider at its Advanced duration');
|
|
84
89
|
assert.ok(defaultSweep.details.some((detail) => detail.id === unknown.id), 'store reaps an unlisted provider at the default duration');
|
|
85
90
|
|
|
91
|
+
const locallyLive = {
|
|
92
|
+
...known,
|
|
93
|
+
id: 'sess_locally_live_keep',
|
|
94
|
+
};
|
|
95
|
+
writeFileSync(join(root, 'sessions', `${locallyLive.id}.json`), JSON.stringify(locallyLive));
|
|
96
|
+
utimesSync(join(root, 'sessions', `${locallyLive.id}.json`), old / 1000, old / 1000);
|
|
97
|
+
const protectedSweep = sweepStaleSessions({
|
|
98
|
+
retainOpenSessions: false,
|
|
99
|
+
isSessionLive: (id) => id === locallyLive.id,
|
|
100
|
+
});
|
|
101
|
+
assert.ok(!protectedSweep.details.some((detail) => detail.id === locallyLive.id), 'store does not reap a locally live stale session');
|
|
102
|
+
assert.notEqual(JSON.parse(readFileSync(join(root, 'sessions', `${locallyLive.id}.json`), 'utf8')).closed, true);
|
|
103
|
+
const settledSweep = sweepStaleSessions({ retainOpenSessions: false });
|
|
104
|
+
assert.ok(settledSweep.details.some((detail) => detail.id === locallyLive.id), 'store reaps the session once local work settles');
|
|
105
|
+
|
|
106
|
+
const heartbeatRace = {
|
|
107
|
+
...known,
|
|
108
|
+
id: 'sess_heartbeat_race_keep',
|
|
109
|
+
};
|
|
110
|
+
const heartbeatRacePath = join(root, 'sessions', `${heartbeatRace.id}.json`);
|
|
111
|
+
writeFileSync(heartbeatRacePath, JSON.stringify(heartbeatRace));
|
|
112
|
+
utimesSync(heartbeatRacePath, old / 1000, old / 1000);
|
|
113
|
+
let livenessChecks = 0;
|
|
114
|
+
const heartbeatRaceSweep = sweepStaleSessions({
|
|
115
|
+
retainOpenSessions: false,
|
|
116
|
+
isSessionLive: (id) => {
|
|
117
|
+
if (id !== heartbeatRace.id) return false;
|
|
118
|
+
livenessChecks++;
|
|
119
|
+
if (livenessChecks === 3) {
|
|
120
|
+
writeFileSync(join(root, 'sessions', `${id}.hb`), '');
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
assert.ok(livenessChecks >= 3, 'store performs the final runtime probe before taking the close lock');
|
|
126
|
+
assert.ok(!heartbeatRaceSweep.details.some((detail) => detail.id === heartbeatRace.id), 'heartbeat landing before the locked re-stat vetoes close');
|
|
127
|
+
assert.notEqual(JSON.parse(readFileSync(heartbeatRacePath, 'utf8')).closed, true);
|
|
128
|
+
|
|
129
|
+
const retentionHeartbeat = {
|
|
130
|
+
...known,
|
|
131
|
+
id: 'sess_retention_heartbeat_keep',
|
|
132
|
+
createdAt: Date.now() - 1_000,
|
|
133
|
+
updatedAt: Date.now() - 1_000,
|
|
134
|
+
};
|
|
135
|
+
const retentionPath = join(root, 'sessions', `${retentionHeartbeat.id}.json`);
|
|
136
|
+
writeFileSync(retentionPath, JSON.stringify(retentionHeartbeat));
|
|
137
|
+
let retentionLivenessChecks = 0;
|
|
138
|
+
const retentionSweep = sweepStaleSessions({
|
|
139
|
+
ttlMs: 60_000,
|
|
140
|
+
openMaxAgeMs: 24 * 60 * 60 * 1000,
|
|
141
|
+
openMaxCount: 0,
|
|
142
|
+
isSessionLive: (id) => {
|
|
143
|
+
if (id !== retentionHeartbeat.id) return false;
|
|
144
|
+
retentionLivenessChecks++;
|
|
145
|
+
if (retentionLivenessChecks === 2) {
|
|
146
|
+
writeFileSync(join(root, 'sessions', `${id}.hb`), '');
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
assert.ok(retentionLivenessChecks >= 2, 'retention performs its runtime probe before taking the delete lock');
|
|
152
|
+
assert.ok(!retentionSweep.openPrunedDetails.some((detail) => detail.id === retentionHeartbeat.id), 'commit-edge heartbeat vetoes retention hard-delete');
|
|
153
|
+
assert.ok(existsSync(retentionPath), 'heartbeating retention candidate survives');
|
|
154
|
+
|
|
155
|
+
const pendingClose = { ...known, id: 'sess_vetoed_close_pending', messages: [] };
|
|
156
|
+
const pendingDelete = { ...known, id: 'sess_vetoed_delete_pending', messages: [] };
|
|
157
|
+
for (const session of [pendingClose, pendingDelete]) {
|
|
158
|
+
writeFileSync(join(root, 'sessions', `${session.id}.json`), JSON.stringify(session));
|
|
159
|
+
saveSession({ ...session, messages: [{ role: 'user', content: 'pending save survived' }] });
|
|
160
|
+
}
|
|
161
|
+
assert.equal(markSessionClosed(pendingClose.id, 'idle-sweep', { isSessionLive: () => true }), null);
|
|
162
|
+
assert.equal(deleteSession(pendingDelete.id, { isSessionLive: () => true }), false);
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
164
|
+
for (const session of [pendingClose, pendingDelete]) {
|
|
165
|
+
const saved = JSON.parse(readFileSync(join(root, 'sessions', `${session.id}.json`), 'utf8'));
|
|
166
|
+
assert.equal(saved.messages[0]?.content, 'pending save survived', 'veto leaves debounce persistence intact');
|
|
167
|
+
assert.notEqual(saved.closed, true);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const {
|
|
171
|
+
_clearSessionRuntime,
|
|
172
|
+
_getRuntimeEntry,
|
|
173
|
+
markSessionAskStart,
|
|
174
|
+
} = await import('../src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs');
|
|
175
|
+
const {
|
|
176
|
+
_finalizeSweptSessionRuntime,
|
|
177
|
+
_runCleanupCycle,
|
|
178
|
+
} = await import('../src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs');
|
|
179
|
+
|
|
180
|
+
const busy = {
|
|
181
|
+
...known,
|
|
182
|
+
id: 'sess_unrelated_busy_keep',
|
|
183
|
+
status: 'running',
|
|
184
|
+
};
|
|
185
|
+
const terminalDuringBusy = {
|
|
186
|
+
...known,
|
|
187
|
+
id: 'sess_terminal_during_busy_reap',
|
|
188
|
+
};
|
|
189
|
+
for (const session of [busy, terminalDuringBusy]) {
|
|
190
|
+
const path = join(root, 'sessions', `${session.id}.json`);
|
|
191
|
+
writeFileSync(path, JSON.stringify(session));
|
|
192
|
+
utimesSync(path, old / 1000, old / 1000);
|
|
193
|
+
}
|
|
194
|
+
markSessionAskStart(busy.id);
|
|
195
|
+
const busyEntry = _getRuntimeEntry(busy.id);
|
|
196
|
+
busyEntry.controller = new AbortController();
|
|
197
|
+
_runCleanupCycle();
|
|
198
|
+
assert.notEqual(JSON.parse(readFileSync(join(root, 'sessions', `${busy.id}.json`), 'utf8')).closed, true, 'unrelated busy runtime survives idle cleanup');
|
|
199
|
+
assert.equal(JSON.parse(readFileSync(join(root, 'sessions', `${terminalDuringBusy.id}.json`), 'utf8')).closed, true, 'terminal session is reaped while unrelated runtime is busy');
|
|
200
|
+
|
|
201
|
+
const postScanRaceId = 'sess_post_scan_active_veto';
|
|
202
|
+
markSessionAskStart(postScanRaceId);
|
|
203
|
+
const postScanEntry = _getRuntimeEntry(postScanRaceId);
|
|
204
|
+
postScanEntry.controller = new AbortController();
|
|
205
|
+
assert.equal(_finalizeSweptSessionRuntime({ id: postScanRaceId }), false, 'post-scan activity vetoes runtime cleanup');
|
|
206
|
+
assert.equal(postScanEntry.controller.signal.aborted, false, 'post-scan controller is not aborted');
|
|
207
|
+
assert.equal(_getRuntimeEntry(postScanRaceId), postScanEntry, 'post-scan runtime remains owned');
|
|
208
|
+
_clearSessionRuntime(busy.id);
|
|
209
|
+
_clearSessionRuntime(postScanRaceId);
|
|
210
|
+
|
|
86
211
|
const dataDir = join(root, 'worker-index');
|
|
87
212
|
mkdirSync(dataDir, { recursive: true });
|
|
88
213
|
writeFileSync(join(dataDir, 'agent-workers.json'), JSON.stringify({
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { recallFastTrackCompactMessages, semanticCompactMessages } from '../src/runtime/agent/orchestrator/session/compact.mjs';
|
|
6
|
+
import {
|
|
7
|
+
buildPostCompactFileAttachment,
|
|
8
|
+
MAX_REATTACH_FILES,
|
|
9
|
+
REATTACH_MAX_TOTAL_TOKENS,
|
|
10
|
+
} from '../src/runtime/agent/orchestrator/session/compact/file-reattach.mjs';
|
|
11
|
+
import { estimateTokens, sanitizeToolPairs } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
12
|
+
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), 'reattach-'));
|
|
14
|
+
const fileA = join(dir, 'a.mjs'); writeFileSync(fileA, 'export const A = 1;\n'.repeat(50));
|
|
15
|
+
const fileB = join(dir, 'b.mjs'); writeFileSync(fileB, 'export const B = 2;\n'.repeat(50));
|
|
16
|
+
const fileHuge = join(dir, 'huge.txt'); writeFileSync(fileHuge, 'h'.repeat(600 * 1024)); // > 512KB cap
|
|
17
|
+
const budgetFiles = Array.from({ length: 5 }, (_, i) => {
|
|
18
|
+
const p = join(dir, `budget-${i}.mjs`);
|
|
19
|
+
writeFileSync(p, `export const budget${i} = "${'token payload '.repeat(4000)}";\n`);
|
|
20
|
+
return p;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const readCall = (id, p) => ({ role: 'assistant', content: '', toolCalls: [{ id, name: 'read', arguments: JSON.stringify({ path: p }) }] });
|
|
24
|
+
const toolRes = (id) => ({ role: 'tool', toolCallId: id, content: 'old cached body '.repeat(100) });
|
|
25
|
+
|
|
26
|
+
function transcript() {
|
|
27
|
+
const msgs = [{ role: 'system', content: 'rules' }];
|
|
28
|
+
msgs.push({ role: 'user', content: 'fix bug in a.mjs' });
|
|
29
|
+
msgs.push(readCall('c1', fileA), toolRes('c1'));
|
|
30
|
+
msgs.push(readCall('c2', fileHuge), toolRes('c2'));
|
|
31
|
+
msgs.push(readCall('c3', join(dir, 'missing.mjs')), toolRes('c3'));
|
|
32
|
+
for (let i = 0; i < 12; i++) { msgs.push({ role: 'user', content: `iterate ${i} ` + 'pad '.repeat(300) }, { role: 'assistant', content: `ok ${i}` }); }
|
|
33
|
+
// newest turn reads fileB — must survive in tail and be skipped
|
|
34
|
+
msgs.push({ role: 'user', content: 'now check b.mjs' });
|
|
35
|
+
msgs.push(readCall('c9', fileB), toolRes('c9'));
|
|
36
|
+
msgs.push({ role: 'assistant', content: 'checked' });
|
|
37
|
+
return msgs;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 1) fasttrack: A re-attached, B (in tail) skipped, huge+missing skipped
|
|
41
|
+
{
|
|
42
|
+
const r = recallFastTrackCompactMessages(transcript(), 4000, { force: true, recallText: 'digest', allowEmptyRecall: true, tailTurns: 2, keepTokens: 2000, cwd: dir });
|
|
43
|
+
const ref = r.messages.find((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith('Reference files:'));
|
|
44
|
+
assert.ok(ref, 'fasttrack: reference-files message injected');
|
|
45
|
+
assert.ok(ref.content.includes(fileA), 'fileA re-attached');
|
|
46
|
+
assert.ok(ref.content.includes('export const A'), 'fileA fresh content present');
|
|
47
|
+
assert.ok(!ref.content.includes(fileHuge), 'huge file skipped');
|
|
48
|
+
assert.ok(!ref.content.includes('missing.mjs'), 'missing file skipped');
|
|
49
|
+
assert.ok(!ref.content.includes(fileB) || !ref.content.includes('export const B'), 'tail-surviving fileB not re-attached');
|
|
50
|
+
const refIdx = r.messages.indexOf(ref);
|
|
51
|
+
assert.equal(r.messages[refIdx + 1]?.role, 'assistant', 'ack follows reference message');
|
|
52
|
+
assert.equal(JSON.stringify(sanitizeToolPairs(r.messages)), JSON.stringify(r.messages), 'pairing valid');
|
|
53
|
+
assert.equal(r.diagnostics.fileReattached, true, 'diagnostics flag set');
|
|
54
|
+
}
|
|
55
|
+
// 2) semantic path with fake provider
|
|
56
|
+
{
|
|
57
|
+
const provider = { name: 'fake', async send() { return { content: '## Goal\n- g\n\n## Constraints & Preferences\n- (none)\n\n## Progress\n### Done\n- d\n\n### In Progress\n- (none)\n\n### Blocked\n- (none)\n\n## Key Decisions\n- (none)\n\n## Next Steps\n- n\n\n## Critical Context\n- c\n\n## Relevant Files\n- a.mjs' }; } };
|
|
58
|
+
const r = await semanticCompactMessages(provider, transcript(), 'fake-model', 4000, { force: true, tailTurns: 1, cwd: dir });
|
|
59
|
+
const ref = r.messages.find((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith('Reference files:'));
|
|
60
|
+
assert.ok(ref, 'semantic: reference-files message injected');
|
|
61
|
+
assert.ok(ref.content.includes('export const A'), 'semantic: fileA fresh content');
|
|
62
|
+
assert.equal(r.diagnostics.fileReattached, true, 'semantic diagnostics flag');
|
|
63
|
+
}
|
|
64
|
+
// 3) no room -> no injection, still valid compact
|
|
65
|
+
{
|
|
66
|
+
const r = recallFastTrackCompactMessages(transcript(), 1500, { force: true, recallText: 'digest', allowEmptyRecall: true, tailTurns: 1, keepTokens: 1200, cwd: dir });
|
|
67
|
+
assert.ok(Array.isArray(r.messages), 'tight budget compact still succeeds');
|
|
68
|
+
const ref = r.messages.find((m) => m.role === 'user' && typeof m.content === 'string' && m.content.startsWith('Reference files:'));
|
|
69
|
+
if (ref) assert.ok(r.diagnostics.finalTokens <= r.diagnostics.budgetTokens, 'reattach never exceeds budget');
|
|
70
|
+
}
|
|
71
|
+
// 4) env off-switch
|
|
72
|
+
{
|
|
73
|
+
process.env.MIXDOG_COMPACT_FILE_REATTACH = '0';
|
|
74
|
+
const off = buildPostCompactFileAttachment([readCall('x', fileA)], [], 10000, { cwd: dir });
|
|
75
|
+
assert.equal(off, null, 'env kill-switch disables reattach');
|
|
76
|
+
delete process.env.MIXDOG_COMPACT_FILE_REATTACH;
|
|
77
|
+
}
|
|
78
|
+
// 5) generous context room is still bounded by the compact re-attach envelope
|
|
79
|
+
{
|
|
80
|
+
const calls = budgetFiles.map((p, i) => readCall(`budget-${i}`, p));
|
|
81
|
+
const ref = buildPostCompactFileAttachment(calls, [], 50_000, { cwd: dir });
|
|
82
|
+
assert.ok(ref, 'bounded attachment should include at least one recent file');
|
|
83
|
+
assert.ok((ref.content.match(/^### /gm) || []).length <= MAX_REATTACH_FILES, 'attachment file count must stay capped');
|
|
84
|
+
assert.ok(estimateTokens(ref.content) <= REATTACH_MAX_TOTAL_TOKENS, 'attachment total tokens must stay capped');
|
|
85
|
+
}
|
|
86
|
+
rmSync(dir, { recursive: true, force: true });
|
|
87
|
+
console.log('compact file-reattach test passed \u2713');
|
|
88
|
+
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import {
|
|
4
5
|
estimateMessagesTokens,
|
|
5
6
|
estimateRequestReserveTokens,
|
|
6
7
|
estimateToolSchemaTokens,
|
|
7
8
|
estimateTokens,
|
|
8
9
|
IMAGE_VISUAL_TOKEN_ALLOWANCE,
|
|
10
|
+
providerTokenCalibration,
|
|
9
11
|
summarizeContextMessages,
|
|
10
12
|
} from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
11
13
|
import { createContextStatus } from '../src/session-runtime/context-status.mjs';
|
|
@@ -176,9 +178,10 @@ test('degenerate main budgets use a documented single-shot fallback', () => {
|
|
|
176
178
|
|
|
177
179
|
const oneToken = { contextWindow: 1, compaction: {}, tools: [] };
|
|
178
180
|
const oneTokenPolicy = policyFor(oneToken);
|
|
179
|
-
assert.
|
|
180
|
-
|
|
181
|
-
|
|
181
|
+
assert.equal(oneTokenPolicy.reserveTokens, 0,
|
|
182
|
+
'an empty request has no synthetic fixed reserve');
|
|
183
|
+
assert.equal(oneTokenPolicy.singleShot, false,
|
|
184
|
+
'the one-token boundary alone does not require a single-shot fallback');
|
|
182
185
|
assert.equal(oneTokenPolicy.triggerTokens, 1);
|
|
183
186
|
assert.equal(compactTargetBudget(oneTokenPolicy), 1);
|
|
184
187
|
|
|
@@ -280,21 +283,32 @@ test('image payload bytes do not inflate live gauge or fallback compaction estim
|
|
|
280
283
|
'estimating live context must not sanitize or remove image content');
|
|
281
284
|
});
|
|
282
285
|
|
|
283
|
-
test('
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
286
|
+
test('token estimates match the real o200k BPE tokenizer at the default multiplier', () => {
|
|
287
|
+
const requireForTest = createRequire(import.meta.url);
|
|
288
|
+
const { Tiktoken } = requireForTest('tiktoken/lite');
|
|
289
|
+
const o200k = requireForTest('tiktoken/encoders/o200k_base.json');
|
|
290
|
+
const enc = new Tiktoken(o200k.bpe_ranks, o200k.special_tokens, o200k.pat_str);
|
|
291
|
+
try {
|
|
292
|
+
for (const text of [
|
|
293
|
+
'A1b2C3d4E5f6G7h8'.repeat(100),
|
|
294
|
+
'%7B%22path%22%3A%22very%2Flong%2Fencoded%2Fvalue%22%7D'.repeat(40),
|
|
295
|
+
JSON.stringify({ rows: Array.from({ length: 100 }, (_, i) => ({ id: i, value: `item_${i}_abcdef` })) }),
|
|
296
|
+
Array.from({ length: 200 }, (_, i) => `{"i":${i}}`).join('\n'),
|
|
297
|
+
'plain english prose with ordinary words that merge well. '.repeat(50),
|
|
298
|
+
'한국어 컨텍스트 추정 정확도 검증 문장입니다. '.repeat(30),
|
|
299
|
+
'😀🚀🔥✅'.repeat(50),
|
|
300
|
+
]) {
|
|
301
|
+
assert.equal(estimateTokens(text), enc.encode(text, undefined, []).length,
|
|
302
|
+
'estimateTokens must equal the real o200k token count');
|
|
303
|
+
}
|
|
304
|
+
// Repeat call exercises the hash-keyed LRU for long strings — the
|
|
305
|
+
// cached count must be identical to the fresh encode.
|
|
306
|
+
const longText = 'cached long payload '.repeat(200);
|
|
307
|
+
assert.equal(estimateTokens(longText), estimateTokens(longText),
|
|
308
|
+
'cached long-string counts must be stable');
|
|
309
|
+
} finally {
|
|
310
|
+
enc.free();
|
|
291
311
|
}
|
|
292
|
-
const shortJsonl = Array.from({ length: 200 }, (_, i) => `{"i":${i}}`).join('\n');
|
|
293
|
-
assert.ok(estimateTokens(shortJsonl) >= shortJsonl.replace(/\s/g, '').length * 0.7,
|
|
294
|
-
'short JSONL records must receive the structured multiline floor');
|
|
295
|
-
const spacedShortIdentifiers = 'A1b2C3d4E5 F6G7H8I9J0 '.repeat(100);
|
|
296
|
-
assert.ok(estimateTokens(spacedShortIdentifiers) >= spacedShortIdentifiers.length * 0.7,
|
|
297
|
-
'space-separated encoded identifiers below the long-run threshold must receive the dense floor');
|
|
298
312
|
});
|
|
299
313
|
|
|
300
314
|
test('assistantBlocks and reasoningItems count provider-visible data but not image bytes', () => {
|
|
@@ -508,7 +522,9 @@ test('provider baselines fingerprint actual sendTools and reject provider, model
|
|
|
508
522
|
const policy = { ...resolveWorkerCompactPolicy(session, session.tools), reserveTokens: 0 };
|
|
509
523
|
return {
|
|
510
524
|
pressure: resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: session }),
|
|
511
|
-
fallback
|
|
525
|
+
// The estimate fallback includes provider billing calibration, so
|
|
526
|
+
// compare against the same pressure computation without a baseline.
|
|
527
|
+
fallback: resolveCompactionPressureTokens(estimateMessagesTokens(messages), policy, { messages, sessionRef: null }),
|
|
512
528
|
};
|
|
513
529
|
};
|
|
514
530
|
for (const mutate of [
|
|
@@ -591,7 +607,8 @@ test('provider callback usage counts assistant output once and estimates only la
|
|
|
591
607
|
const wholeEstimate = estimateMessagesTokens(messages);
|
|
592
608
|
assert.ok(wholeEstimate < policy.triggerTokens, 'fixture must reproduce local estimator undercount');
|
|
593
609
|
const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
|
|
594
|
-
const expectedPressure = 94_800
|
|
610
|
+
const expectedPressure = 94_800
|
|
611
|
+
+ Math.round(estimateMessagesTokens([laterToolResult]) * providerTokenCalibration('anthropic'));
|
|
595
612
|
assert.equal(pressure, expectedPressure, 'assistant output/reasoning must stay in actual usage, not be estimated again');
|
|
596
613
|
assert.ok(pressure >= 95_000, `actual usage plus later tool growth should cross trigger, got ${pressure}`);
|
|
597
614
|
assert.equal(shouldCompactForSession(wholeEstimate, policy, {
|
|
@@ -619,7 +636,8 @@ test('thinking-only continuation without assistant replay excludes provider outp
|
|
|
619
636
|
|
|
620
637
|
const wholeEstimate = estimateMessagesTokens(messages);
|
|
621
638
|
const pressure = compactionTelemetryPressureTokens(wholeEstimate, policy, { messages, sessionRef: session });
|
|
622
|
-
const expectedPressure = 74_000
|
|
639
|
+
const expectedPressure = 74_000
|
|
640
|
+
+ Math.round(estimateMessagesTokens([nudge]) * providerTokenCalibration('anthropic'));
|
|
623
641
|
assert.equal(pressure, expectedPressure, 'unreplayed output must be removed while the later nudge is estimated');
|
|
624
642
|
assert.equal(shouldCompactForSession(wholeEstimate, policy, {
|
|
625
643
|
messages,
|
|
@@ -639,22 +657,25 @@ test('OpenAI OAuth WS warmup remains billed but does not double the main context
|
|
|
639
657
|
const usage = _combineUsageWithWarmup(main, main, { separateMainContext: true });
|
|
640
658
|
assert.equal(usage.inputTokens, 335_270, 'billing usage must retain warmup plus main input');
|
|
641
659
|
const { initProviders, getProvider } = await import('../src/runtime/agent/orchestrator/providers/registry.mjs');
|
|
642
|
-
const { createSession, askSession, getSession } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
|
|
660
|
+
const { createSession, askSession, getSession, closeSession } = await import('../src/runtime/agent/orchestrator/session/manager.mjs');
|
|
643
661
|
await initProviders({ 'openai-oauth': { enabled: true } });
|
|
644
662
|
const provider = getProvider('openai-oauth');
|
|
645
663
|
const originalSend = provider.send;
|
|
646
664
|
const compactEvents = [];
|
|
665
|
+
let regressionSessionId = '';
|
|
647
666
|
provider.send = async () => ({ content: 'done', usage });
|
|
648
667
|
try {
|
|
649
668
|
const session = createSession({
|
|
650
669
|
provider: 'openai-oauth',
|
|
651
670
|
model: 'warmup-context-regression',
|
|
671
|
+
owner: 'test',
|
|
652
672
|
tools: [],
|
|
653
673
|
cwd: process.cwd(),
|
|
654
674
|
skipAgentRules: true,
|
|
655
675
|
skipSkills: true,
|
|
656
676
|
compaction: { auto: true },
|
|
657
677
|
});
|
|
678
|
+
regressionSessionId = session.id;
|
|
658
679
|
session.contextWindow = 272_000;
|
|
659
680
|
session.rawContextWindow = 272_000;
|
|
660
681
|
await askSession(session.id, 'large OAuth WebSocket request', null, null, process.cwd(), null, {
|
|
@@ -680,6 +701,9 @@ test('OpenAI OAuth WS warmup remains billed but does not double the main context
|
|
|
680
701
|
}), false, 'main-request pressure must not trigger compaction');
|
|
681
702
|
assert.equal(compactEvents.length, 0, 'warmup must not cause an auto-compaction');
|
|
682
703
|
} finally {
|
|
704
|
+
if (regressionSessionId) {
|
|
705
|
+
closeSession(regressionSessionId, 'warmup-context-regression-cleanup', { tombstone: true });
|
|
706
|
+
}
|
|
683
707
|
provider.send = originalSend;
|
|
684
708
|
}
|
|
685
709
|
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
compactDigestRows,
|
|
4
|
+
renderEntryLines,
|
|
5
|
+
} from '../src/runtime/memory/lib/recall-format.mjs';
|
|
6
|
+
import { createQueryHandlers } from '../src/runtime/memory/lib/query-handlers.mjs';
|
|
7
|
+
|
|
8
|
+
const longPlan = 'cache recent session snapshots and display immediately while the runtime initializes in the background without a blocking veil';
|
|
9
|
+
const nearPlan = `${longPlan} and keep the newest click authoritative`;
|
|
10
|
+
const rows = [
|
|
11
|
+
{ id: 10, ts: 300, role: 'assistant', content: nearPlan, is_root: 0, chunk_root: null },
|
|
12
|
+
{ id: 9, ts: 290, role: 'assistant', content: longPlan, is_root: 0, chunk_root: null },
|
|
13
|
+
{ id: 8, ts: 280, role: 'assistant', content: longPlan, is_root: 0, chunk_root: null },
|
|
14
|
+
{ id: 7, ts: 270, role: 'user', content: '오케이', is_root: 0, chunk_root: null },
|
|
15
|
+
{ id: 6, ts: 260, role: 'user', content: '오케이', is_root: 0, chunk_root: null },
|
|
16
|
+
{ id: 5, ts: 250, role: 'assistant', content: 'distinct completed result', is_root: 0, chunk_root: null },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const compact = compactDigestRows(rows, 30);
|
|
20
|
+
assert.equal(compact.filter((row) => row.content === '오케이').length, 1, 'short exact duplicates collapse');
|
|
21
|
+
assert.equal(compact.filter((row) => row.content === longPlan).length, 0, 'older near-duplicate plans collapse');
|
|
22
|
+
assert.ok(compact.some((row) => row.content === nearPlan), 'newest near-duplicate plan is retained');
|
|
23
|
+
assert.ok(compact.some((row) => row.content === 'distinct completed result'), 'distinct state survives');
|
|
24
|
+
|
|
25
|
+
const normalText = renderEntryLines(compact);
|
|
26
|
+
assert.match(normalText, /\[pending\]/, 'normal recall keeps raw-row pipeline status');
|
|
27
|
+
const digestText = renderEntryLines(compact, { pendingMarks: false });
|
|
28
|
+
assert.doesNotMatch(digestText, /\[pending\]/, 'compact digest omits misleading pipeline status');
|
|
29
|
+
|
|
30
|
+
const fakeDb = {
|
|
31
|
+
async query(sql) {
|
|
32
|
+
if (/SELECT source_turn t/.test(sql)) return { rows: [] };
|
|
33
|
+
if (/id <> ALL/.test(sql)) return { rows: [] };
|
|
34
|
+
if (/FROM entries/.test(sql)) return { rows };
|
|
35
|
+
return { rows: [] };
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const { handleSearch } = createQueryHandlers({
|
|
39
|
+
getDb: () => fakeDb,
|
|
40
|
+
log: () => {},
|
|
41
|
+
resolveProjectScope: () => null,
|
|
42
|
+
embeddingWarmupCanStart: () => false,
|
|
43
|
+
getBootTimestamp: () => 0,
|
|
44
|
+
getTraceDb: () => null,
|
|
45
|
+
});
|
|
46
|
+
const integrated = await handleSearch({
|
|
47
|
+
sessionId: 'compact-digest-session',
|
|
48
|
+
limit: 30,
|
|
49
|
+
includeMembers: true,
|
|
50
|
+
includeRaw: true,
|
|
51
|
+
compactDigest: true,
|
|
52
|
+
});
|
|
53
|
+
assert.doesNotMatch(integrated.text, /\[pending\]/, 'compact search path suppresses pipeline status');
|
|
54
|
+
assert.equal((integrated.text.match(/오케이/g) || []).length, 1, 'compact search path removes exact duplicates');
|
|
55
|
+
assert.equal((integrated.text.match(/cache recent session snapshots/g) || []).length, 1, 'compact search path removes near duplicates');
|
|
56
|
+
|
|
57
|
+
console.log('compact recall digest test passed \u2713');
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { semanticCompactMessages, recallFastTrackCompactMessages, SUMMARY_PREFIX, COMPACT_TYPE_SEMANTIC, COMPACT_TYPE_RECALL_FASTTRACK, normalizeCompactType } from '../src/runtime/agent/orchestrator/session/compact.mjs';
|
|
3
4
|
import { agentLoop } from '../src/runtime/agent/orchestrator/session/loop.mjs';
|
|
4
|
-
import { estimateMessagesTokens, estimateToolSchemaTokens } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
5
|
+
import { estimateMessagesTokens, estimateToolSchemaTokens, providerTokenCalibration } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
|
|
5
6
|
import { runSessionCompaction } from '../src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs';
|
|
6
7
|
import { autoCompactWindowForRoute, summarizeGatewayUsage } from '../src/vendor/statusline/src/gateway/route-meta.mjs';
|
|
7
8
|
|
|
@@ -928,58 +929,53 @@ assert(!tinyCapUserContent.startsWith('[...'), 'tiny cap recall must not use a p
|
|
|
928
929
|
// ---------------------------------------------------------------------------
|
|
929
930
|
// Conservative Unicode-aware estimator (strict-fit hardening).
|
|
930
931
|
//
|
|
931
|
-
// The estimator
|
|
932
|
-
//
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
932
|
+
// The estimator now encodes the provider-visible projection with the real
|
|
933
|
+
// o200k_base BPE (tiktoken), so local estimates equal actual tokenizer counts
|
|
934
|
+
// for every script (Korean/CJK/emoji/ASCII alike). Provider-specific billing
|
|
935
|
+
// deltas (e.g. Anthropic ~1.7x o200k) are applied separately via
|
|
936
|
+
// providerTokenCalibration at the pressure/gauge aggregation boundary.
|
|
937
|
+
const requireForTokenizer = createRequire(import.meta.url);
|
|
938
|
+
const { Tiktoken: SmokeTiktoken } = requireForTokenizer('tiktoken/lite');
|
|
939
|
+
const smokeO200k = requireForTokenizer('tiktoken/encoders/o200k_base.json');
|
|
940
|
+
const smokeEncoder = new SmokeTiktoken(smokeO200k.bpe_ranks, smokeO200k.special_tokens, smokeO200k.pat_str);
|
|
941
|
+
const bpeCount = (text) => smokeEncoder.encode(String(text), undefined, []).length;
|
|
942
|
+
|
|
943
|
+
for (const [label, probe] of [
|
|
944
|
+
['korean', '\uD55C\uAD6D\uC5B4 \uCEF4\uD329\uC158 \uACBD\uACC4 \uD14C\uC2A4\uD2B8 '.repeat(200)],
|
|
945
|
+
['cjk', '上下文压缩边界测试令牌预算'.repeat(200)],
|
|
946
|
+
['emoji', '😀🚀🔥✅'.repeat(200)],
|
|
947
|
+
['ascii', 'plain ascii sentence with normal words. '.repeat(200)],
|
|
948
|
+
]) {
|
|
949
|
+
const est = estimateMessagesTokens([{ role: 'user', content: probe }]);
|
|
950
|
+
const expected = bpeCount(probe) + 4;
|
|
951
|
+
assert(
|
|
952
|
+
est === expected,
|
|
953
|
+
`${label} message estimate must equal the real o200k count plus framing (est=${est}, expected=${expected})`,
|
|
954
|
+
);
|
|
939
955
|
}
|
|
940
956
|
|
|
941
|
-
|
|
942
|
-
const koEst = estimateMessagesTokens([{ role: 'user', content: koHeavy }]);
|
|
943
|
-
assert(
|
|
944
|
-
koEst > chars4(koHeavy) * 2,
|
|
945
|
-
`Korean-heavy content must estimate well above chars/4 (est=${koEst}, chars/4=${chars4(koHeavy)})`,
|
|
946
|
-
);
|
|
947
|
-
|
|
948
|
-
const cjkHeavy = '上下文压缩边界测试令牌预算'.repeat(200);
|
|
949
|
-
const cjkEst = estimateMessagesTokens([{ role: 'user', content: cjkHeavy }]);
|
|
950
|
-
assert(
|
|
951
|
-
cjkEst > chars4(cjkHeavy) * 1.5,
|
|
952
|
-
`CJK-heavy content must estimate above chars/4 (est=${cjkEst}, chars/4=${chars4(cjkHeavy)})`,
|
|
953
|
-
);
|
|
954
|
-
|
|
955
|
-
const emojiHeavy = '😀🚀🔥✅'.repeat(200);
|
|
956
|
-
const emojiEst = estimateMessagesTokens([{ role: 'user', content: emojiHeavy }]);
|
|
957
|
-
assert(
|
|
958
|
-
emojiEst > chars4(emojiHeavy) * 2,
|
|
959
|
-
`Emoji-heavy content must estimate well above chars/4 (est=${emojiEst}, chars/4=${chars4(emojiHeavy)})`,
|
|
960
|
-
);
|
|
961
|
-
|
|
962
|
-
// Plain ASCII must stay close to the chars/4 floor (within the safety
|
|
963
|
-
// multiplier band) — the estimator overcounts non-ASCII, not everything.
|
|
964
|
-
const asciiPlain = 'plain ascii sentence with normal words. '.repeat(200);
|
|
965
|
-
const asciiEst = estimateMessagesTokens([{ role: 'user', content: asciiPlain }]);
|
|
957
|
+
// Provider calibration reconciles o200k counts with actual billing.
|
|
966
958
|
assert(
|
|
967
|
-
|
|
968
|
-
|
|
959
|
+
providerTokenCalibration('anthropic-oauth') > 1.5 && providerTokenCalibration('anthropic-oauth') < 2,
|
|
960
|
+
'anthropic calibration must reflect the measured ~1.7x billed/o200k ratio',
|
|
969
961
|
);
|
|
962
|
+
assert(providerTokenCalibration('openai-oauth') === 1.0, 'openai calibration must stay neutral');
|
|
963
|
+
assert(providerTokenCalibration(undefined) === 1.0, 'unknown provider calibration must stay neutral');
|
|
970
964
|
|
|
971
965
|
// Tool schemas are serialized into the request body, so their estimate must
|
|
972
|
-
//
|
|
966
|
+
// track the real tokenizer over the wire-shape serialization.
|
|
973
967
|
const toolSchema = [{
|
|
974
968
|
name: 'apply_patch',
|
|
975
969
|
description: '\uD328\uCE58\uB97C \uD30C\uC77C\uC5D0 \uC801\uC6A9 (\uD55C\uAD6D\uC5B4 \uC124\uBA85) — applies a patch',
|
|
976
970
|
parameters: { type: 'object', properties: { patch: { type: 'string', description: '\uD328\uCE58 \uBCF8\uBB38 \uD14D\uC2A4\uD2B8' } } },
|
|
977
971
|
}];
|
|
978
972
|
const toolSchemaEst = estimateToolSchemaTokens(toolSchema);
|
|
973
|
+
const toolWireJson = JSON.stringify(toolSchema.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })));
|
|
979
974
|
assert(
|
|
980
|
-
toolSchemaEst
|
|
981
|
-
`tool schema estimate must
|
|
975
|
+
toolSchemaEst === bpeCount(toolWireJson),
|
|
976
|
+
`tool schema estimate must equal the o200k count of the serialized wire JSON (est=${toolSchemaEst}, expected=${bpeCount(toolWireJson)})`,
|
|
982
977
|
);
|
|
978
|
+
smokeEncoder.free();
|
|
983
979
|
|
|
984
980
|
// Strict-fit smoke: a Korean/CJK-heavy newest turn far larger than the preserve
|
|
985
981
|
// budget must be summarized/truncated safely — never preserved verbatim — and
|