mixdog 0.9.52 → 0.9.53
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 +1 -1
- package/scripts/bench-run.mjs +2 -2
- package/scripts/compact-pressure-test.mjs +104 -0
- package/scripts/desktop-session-bridge-test.mjs +704 -0
- package/scripts/freevar-smoke.mjs +7 -4
- package/scripts/lifecycle-api-test.mjs +65 -4
- package/scripts/max-output-recovery-test.mjs +31 -0
- package/scripts/memory-core-input-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +63 -3
- package/scripts/openai-ws-early-settle-test.mjs +40 -0
- package/scripts/parent-abort-link-test.mjs +24 -0
- package/scripts/process-lifecycle-test.mjs +80 -22
- package/scripts/provider-contract-test.mjs +257 -0
- package/scripts/provider-toolcall-test.mjs +172 -10
- package/scripts/session-orphan-sweep-test.mjs +27 -1
- package/src/lib/keychain-cjs.cjs +36 -23
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
- package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -300
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -4
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +18 -266
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +18 -1
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +15 -130
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +5 -115
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
- package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
- package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -71
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +47 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +1 -7
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +72 -77
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -7
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -5
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
- package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
- package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
- package/src/runtime/channels/backends/discord.mjs +6 -6
- package/src/runtime/channels/lib/config.mjs +15 -2
- package/src/runtime/channels/lib/memory-client.mjs +20 -3
- package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
- package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
- package/src/runtime/channels/lib/worker-main.mjs +16 -5
- package/src/runtime/memory/index.mjs +24 -198
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
- package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
- package/src/runtime/memory/lib/query-handlers.mjs +2 -122
- package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
- package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
- package/src/runtime/search/lib/http-fetch.mjs +1 -1
- package/src/runtime/shared/config.mjs +58 -13
- package/src/runtime/shared/llm/cost.mjs +14 -4
- package/src/runtime/shared/memory-snapshot.mjs +236 -0
- package/src/runtime/shared/process-lifecycle.mjs +92 -19
- package/src/runtime/shared/process-shutdown.mjs +6 -0
- package/src/runtime/shared/resource-admission.mjs +7 -2
- package/src/session-runtime/channel-config-api.mjs +7 -7
- package/src/session-runtime/config-lifecycle.mjs +20 -17
- package/src/session-runtime/cwd-plugins.mjs +9 -7
- package/src/session-runtime/env.mjs +1 -2
- package/src/session-runtime/hitch-profile.mjs +45 -0
- package/src/session-runtime/lifecycle-api.mjs +36 -9
- package/src/session-runtime/mcp-glue.mjs +6 -11
- package/src/session-runtime/provider-init-key.mjs +17 -0
- package/src/session-runtime/runtime-core.mjs +44 -103
- package/src/session-runtime/runtime-paths.mjs +20 -0
- package/src/session-runtime/runtime-tool-routing.mjs +55 -0
- package/src/session-runtime/tool-catalog-data.mjs +51 -0
- package/src/session-runtime/tool-catalog.mjs +15 -89
- package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
- package/src/standalone/agent-tool/worker-rows.mjs +93 -0
- package/src/standalone/agent-tool.mjs +12 -162
- package/src/standalone/channel-admin.mjs +29 -0
- package/src/tui/App.jsx +11 -5
- package/src/tui/dist/index.mjs +202 -65
- package/src/tui/engine/session-api-ext.mjs +37 -4
- package/src/tui/index.jsx +1 -0
- package/src/tui/lib/voice-setup.mjs +5 -5
- package/scripts/_devtools-stub.mjs +0 -1
- package/src/runtime/lib/keychain-cjs.cjs +0 -288
|
@@ -20,8 +20,11 @@ import { analyze } from 'eslint-scope';
|
|
|
20
20
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
21
|
// Plain .mjs source roots. src/tui is covered by smoke:tui on the BUILT
|
|
22
22
|
// bundle (App.jsx is JSX — acorn can't parse it raw); vendor is third-party.
|
|
23
|
-
const SCAN_ROOTS = ['src/runtime', 'src/standalone', 'src/shared', 'scripts'].map(p => join(ROOT, p));
|
|
24
|
-
const
|
|
23
|
+
const SCAN_ROOTS = ['src/runtime', 'src/session-runtime', 'src/standalone', 'src/shared', 'scripts'].map(p => join(ROOT, p));
|
|
24
|
+
const ROOT_RUNTIME_FACADES = readdirSync(join(ROOT, 'src'), { withFileTypes: true })
|
|
25
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.mjs'))
|
|
26
|
+
.map(entry => join(ROOT, 'src', entry.name));
|
|
27
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'vendor', '.git']);
|
|
25
28
|
|
|
26
29
|
const KNOWN_GLOBALS = new Set([
|
|
27
30
|
'Array', 'ArrayBuffer', 'Atomics', 'BigInt', 'BigInt64Array', 'BigUint64Array', 'Boolean',
|
|
@@ -61,9 +64,9 @@ function* walk(dir) {
|
|
|
61
64
|
|
|
62
65
|
let failures = 0;
|
|
63
66
|
let scanned = 0;
|
|
64
|
-
for (const root of SCAN_ROOTS) {
|
|
67
|
+
for (const root of [...SCAN_ROOTS, ...ROOT_RUNTIME_FACADES]) {
|
|
65
68
|
let files;
|
|
66
|
-
try { files = [...walk(root)]; } catch { continue; }
|
|
69
|
+
try { files = statSync(root).isDirectory() ? [...walk(root)] : [root]; } catch { continue; }
|
|
67
70
|
for (const file of files) {
|
|
68
71
|
scanned++;
|
|
69
72
|
const source = readFileSync(file, 'utf8');
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import test from 'node:test';
|
|
3
6
|
|
|
7
|
+
import { createConfigLifecycle } from '../src/session-runtime/config-lifecycle.mjs';
|
|
4
8
|
import { createLifecycleApi } from '../src/session-runtime/lifecycle-api.mjs';
|
|
5
9
|
import {
|
|
6
10
|
_clearWebSocketPoolForTest,
|
|
@@ -17,7 +21,7 @@ function socket() {
|
|
|
17
21
|
};
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
function lifecycleFor(session) {
|
|
24
|
+
function lifecycleFor(session, overrides = {}) {
|
|
21
25
|
let current = session;
|
|
22
26
|
return createLifecycleApi({
|
|
23
27
|
getSession: () => current,
|
|
@@ -45,15 +49,14 @@ function lifecycleFor(session) {
|
|
|
45
49
|
mcpClient: { disconnectAll: () => null },
|
|
46
50
|
warmupTimers: {},
|
|
47
51
|
prewarmTimers: {},
|
|
48
|
-
|
|
49
|
-
flushBackendSave: () => {},
|
|
50
|
-
flushOutputStyleSave: () => {},
|
|
52
|
+
flushAllConfigSavesAsync: async () => {},
|
|
51
53
|
withTeardownDeadline: (promise) => promise,
|
|
52
54
|
closePatchRuntimeIfLoaded: () => null,
|
|
53
55
|
invalidateContextStatusCache: () => {},
|
|
54
56
|
invalidatePreSessionToolSurface: () => {},
|
|
55
57
|
notificationListeners: { clear: () => {} },
|
|
56
58
|
remoteStateListeners: { clear: () => {} },
|
|
59
|
+
...overrides,
|
|
57
60
|
});
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -74,3 +77,61 @@ test('lifecycle drains the OpenAI WS pool only for process exit', async () => {
|
|
|
74
77
|
|
|
75
78
|
assert.deepEqual(retainedSocket.closed, ['cli-exit']);
|
|
76
79
|
});
|
|
80
|
+
|
|
81
|
+
test('lifecycle barrier drains a direct updateSectionAsync with no queued lifecycle save', async () => {
|
|
82
|
+
const dataDir = mkdtempSync(join(tmpdir(), 'mixdog-lifecycle-config-'));
|
|
83
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
84
|
+
const previousBackupRoot = process.env.MIXDOG_USER_DATA_BACKUP_ROOT;
|
|
85
|
+
process.env.MIXDOG_DATA_DIR = dataDir;
|
|
86
|
+
process.env.MIXDOG_USER_DATA_BACKUP_ROOT = join(dataDir, 'backups');
|
|
87
|
+
const sharedCfgMod = await import(`../src/runtime/shared/config.mjs?lifecycle-tail=${Date.now()}`);
|
|
88
|
+
const configLifecycle = createConfigLifecycle({
|
|
89
|
+
getConfig: () => ({}),
|
|
90
|
+
setConfig: () => {},
|
|
91
|
+
getSearchRoute: () => null,
|
|
92
|
+
setSearchRoute: () => {},
|
|
93
|
+
getConfigHasSecrets: () => false,
|
|
94
|
+
setConfigHasSecrets: () => {},
|
|
95
|
+
getRoute: () => ({}),
|
|
96
|
+
cfgMod: {
|
|
97
|
+
saveConfigAsync: async () => {},
|
|
98
|
+
patchSkillsDisabledAsync: async () => {},
|
|
99
|
+
getPluginData: () => dataDir,
|
|
100
|
+
},
|
|
101
|
+
sharedCfgMod,
|
|
102
|
+
setBackendAsync: async () => {},
|
|
103
|
+
setConfiguredShell: () => {},
|
|
104
|
+
normalizeSystemShellConfig: () => ({ command: '' }),
|
|
105
|
+
normalizeSearchRouteConfig: () => null,
|
|
106
|
+
outputStyleStatus: () => ({}),
|
|
107
|
+
LAZY_SECRET_PROVIDERS: new Set(),
|
|
108
|
+
clean: (value) => String(value || ''),
|
|
109
|
+
resolve: (value) => value,
|
|
110
|
+
STANDALONE_DATA_DIR: dataDir,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const events = [];
|
|
114
|
+
let directSettled = false;
|
|
115
|
+
try {
|
|
116
|
+
const directWrite = sharedCfgMod.updateSectionAsync('cycle3', () => ({ value: 'direct' }))
|
|
117
|
+
.finally(() => { directSettled = true; events.push('direct:settled'); });
|
|
118
|
+
assert.equal(directSettled, false);
|
|
119
|
+
|
|
120
|
+
const lifecycle = lifecycleFor(
|
|
121
|
+
{ id: 'tail-drain', messages: [], liveTurnMessages: [] },
|
|
122
|
+
{
|
|
123
|
+
flushAllConfigSavesAsync: configLifecycle.flushAllConfigSavesAsync,
|
|
124
|
+
closePatchRuntimeIfLoaded: async () => { events.push('teardown:continued'); },
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
await lifecycle.close('engine-replace');
|
|
128
|
+
await directWrite;
|
|
129
|
+
assert.deepEqual(events, ['direct:settled', 'teardown:continued']);
|
|
130
|
+
} finally {
|
|
131
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
132
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
133
|
+
if (previousBackupRoot == null) delete process.env.MIXDOG_USER_DATA_BACKUP_ROOT;
|
|
134
|
+
else process.env.MIXDOG_USER_DATA_BACKUP_ROOT = previousBackupRoot;
|
|
135
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
@@ -118,6 +118,37 @@ test('Gemini MAX_TOKENS ProviderIncompleteError enters the same bounded recovery
|
|
|
118
118
|
assert.equal(provider.sent[1][1].content, 'gemini ');
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
test('Gemini MAX_TOKENS recovery derives missing prompt usage from total minus output', async () => {
|
|
122
|
+
const incomplete = Object.assign(new Error('Gemini response incomplete: finishReason=MAX_TOKENS'), {
|
|
123
|
+
code: 'PROVIDER_INCOMPLETE',
|
|
124
|
+
providerIncomplete: true,
|
|
125
|
+
finishReason: 'MAX_TOKENS',
|
|
126
|
+
partialContent: 'gemini ',
|
|
127
|
+
rawUsage: {
|
|
128
|
+
total_token_count: 20,
|
|
129
|
+
candidates_token_count: 4,
|
|
130
|
+
thoughts_token_count: 1,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const result = await run(queuedProvider([
|
|
134
|
+
incomplete,
|
|
135
|
+
{
|
|
136
|
+
content: 'complete',
|
|
137
|
+
stopReason: 'end_turn',
|
|
138
|
+
usage: { inputTokens: 1, outputTokens: 2, promptTokens: 1 },
|
|
139
|
+
},
|
|
140
|
+
]), undefined, { onTextDelta: () => {} });
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(
|
|
143
|
+
{
|
|
144
|
+
inputTokens: result.usage.inputTokens,
|
|
145
|
+
outputTokens: result.usage.outputTokens,
|
|
146
|
+
promptTokens: result.usage.promptTokens,
|
|
147
|
+
},
|
|
148
|
+
{ inputTokens: 16, outputTokens: 7, promptTokens: 16 },
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
121
152
|
test('Gemini MAX_TOKENS continuation preserves provider-scoped replay metadata', async () => {
|
|
122
153
|
const providerMetadata = {
|
|
123
154
|
gemini: { thoughtParts: [{ text: 'signed thought', thoughtSignature: 'sig-gemini' }] },
|
|
@@ -7,6 +7,7 @@ import { normalizeCoreInput } from '../src/runtime/memory/lib/core-memory-store.
|
|
|
7
7
|
import { createMemoryActionHandlers } from '../src/runtime/memory/lib/memory-action-handlers.mjs'
|
|
8
8
|
import { TOOL_DEFS as MEMORY_TOOL_DEFS } from '../src/runtime/memory/tool-defs.mjs'
|
|
9
9
|
import { parseMemoryCandidateRows, parseMemoryCoreRows } from '../src/tui/app/input-parsers.mjs'
|
|
10
|
+
import { memoryToolArgsForCaller } from '../src/session-runtime/runtime-core.mjs'
|
|
10
11
|
|
|
11
12
|
test('memory mutation schema omits category while recall keeps its internal filter', () => {
|
|
12
13
|
const memoryTool = MEMORY_TOOL_DEFS.find((tool) => tool.name === 'memory')
|
|
@@ -16,6 +17,15 @@ test('memory mutation schema omits category while recall keeps its internal filt
|
|
|
16
17
|
assert.equal(Object.hasOwn(recallTool.inputSchema.properties, 'category'), true)
|
|
17
18
|
})
|
|
18
19
|
|
|
20
|
+
test('memory tool calls inherit the active caller cwd only when cwd is omitted', () => {
|
|
21
|
+
assert.deepEqual(memoryToolArgsForCaller({ action: 'status' }, '/active/project'), {
|
|
22
|
+
action: 'status',
|
|
23
|
+
cwd: '/active/project',
|
|
24
|
+
})
|
|
25
|
+
const explicit = { action: 'status', cwd: '/explicit/project' }
|
|
26
|
+
assert.equal(memoryToolArgsForCaller(explicit, '/active/project'), explicit)
|
|
27
|
+
})
|
|
28
|
+
|
|
19
29
|
test('core content aliases summary, derives an element, and accepts no category', () => {
|
|
20
30
|
const content = 'A durable preference that callers should receive concise answers.'
|
|
21
31
|
const input = normalizeCoreInput({ content }, {
|
|
@@ -5,6 +5,7 @@ import { EventEmitter } from 'node:events';
|
|
|
5
5
|
import { OpenAIOAuthProvider } from '../src/runtime/agent/orchestrator/providers/openai-oauth.mjs';
|
|
6
6
|
import { _acquireWithRetry, sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
|
|
7
7
|
import { sendViaHttpSse } from '../src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs';
|
|
8
|
+
import { applyAskTerminalUsageTotals } from '../src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs';
|
|
8
9
|
import {
|
|
9
10
|
_clearWebSocketPoolForTest,
|
|
10
11
|
_closeAllPooledSockets,
|
|
@@ -247,6 +248,42 @@ test('pre-response 1006 opens a fresh WS and replays the same request', async ()
|
|
|
247
248
|
assert.doesNotMatch(stderr, /mid-stream recovered|Reconnecting/);
|
|
248
249
|
});
|
|
249
250
|
|
|
251
|
+
test('completed warmup usage survives a same-send WS retry exactly once', async () => {
|
|
252
|
+
let streams = 0;
|
|
253
|
+
const result = await sendViaWebSocket(wsArgs({
|
|
254
|
+
warmupBody: { model: 'gpt-5.5', instructions: 'base', input: [], generate: false },
|
|
255
|
+
_acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
|
|
256
|
+
_streamFn: async ({ state }) => {
|
|
257
|
+
streams += 1;
|
|
258
|
+
if (state.warmup) {
|
|
259
|
+
return {
|
|
260
|
+
content: '',
|
|
261
|
+
model: 'gpt-5.5',
|
|
262
|
+
toolCalls: [],
|
|
263
|
+
usage: { inputTokens: 10, outputTokens: 0, cachedTokens: 4, promptTokens: 10 },
|
|
264
|
+
responseId: 'warm-retry-1',
|
|
265
|
+
responseItems: [],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (streams === 2) throw close1006();
|
|
269
|
+
return {
|
|
270
|
+
content: 'recovered',
|
|
271
|
+
model: 'gpt-5.5',
|
|
272
|
+
toolCalls: [],
|
|
273
|
+
usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 8, promptTokens: 20 },
|
|
274
|
+
responseId: 'main-retry-1',
|
|
275
|
+
responseItems: [],
|
|
276
|
+
closeSocket: true,
|
|
277
|
+
};
|
|
278
|
+
},
|
|
279
|
+
}));
|
|
280
|
+
assert.equal(streams, 3, 'one warmup plus two main attempts');
|
|
281
|
+
assert.equal(result.usage.inputTokens, 30);
|
|
282
|
+
assert.equal(result.usage.outputTokens, 2);
|
|
283
|
+
assert.equal(result.usage.mainInputTokens, 20);
|
|
284
|
+
assert.equal(result.usage.warmupInputTokens, 10);
|
|
285
|
+
});
|
|
286
|
+
|
|
250
287
|
test('successful iteration emits one compact send-spans row', async () => {
|
|
251
288
|
const rows = [];
|
|
252
289
|
const result = await sendViaWebSocket(wsArgs({
|
|
@@ -311,7 +348,7 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
311
348
|
Object.assign(process.env, {
|
|
312
349
|
MIXDOG_OAI_TRANSPORT: 'auto',
|
|
313
350
|
MIXDOG_OPENAI_HTTP_FALLBACK: '1',
|
|
314
|
-
MIXDOG_OPENAI_OAUTH_WS_WARMUP: '
|
|
351
|
+
MIXDOG_OPENAI_OAUTH_WS_WARMUP: '1',
|
|
315
352
|
MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK: '0',
|
|
316
353
|
MIXDOG_AGENT_TRACE_DISABLE: '1',
|
|
317
354
|
});
|
|
@@ -327,7 +364,17 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
327
364
|
...args,
|
|
328
365
|
_acquireWithRetryFn: async () => ({ entry: entry(), reused: false }),
|
|
329
366
|
_sendFrameFn: async () => {},
|
|
330
|
-
_streamFn: async () => {
|
|
367
|
+
_streamFn: async ({ state }) => {
|
|
368
|
+
if (state.warmup) {
|
|
369
|
+
return {
|
|
370
|
+
content: '',
|
|
371
|
+
model: 'gpt-5.5',
|
|
372
|
+
toolCalls: [],
|
|
373
|
+
usage: { inputTokens: 10, outputTokens: 0, cachedTokens: 4, promptTokens: 10 },
|
|
374
|
+
responseId: 'warm-fallback-1',
|
|
375
|
+
responseItems: [],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
331
378
|
streamAttempts += 1;
|
|
332
379
|
throw close1006();
|
|
333
380
|
},
|
|
@@ -335,7 +382,11 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
335
382
|
}),
|
|
336
383
|
_sendViaHttpSseFn: async () => {
|
|
337
384
|
httpCalls += 1;
|
|
338
|
-
return {
|
|
385
|
+
return {
|
|
386
|
+
content: 'http-recovered',
|
|
387
|
+
toolCalls: [],
|
|
388
|
+
usage: { inputTokens: 20, outputTokens: 2, cachedTokens: 8, promptTokens: 20 },
|
|
389
|
+
};
|
|
339
390
|
},
|
|
340
391
|
};
|
|
341
392
|
const result = await provider.send([], 'gpt-5.5', [], sendOpts);
|
|
@@ -345,6 +396,15 @@ test('exhausted pre-response 1006 retries reach the HTTP/SSE fallback', async ()
|
|
|
345
396
|
assert.equal(httpCalls, 2);
|
|
346
397
|
assert.equal(result.content, 'http-recovered');
|
|
347
398
|
assert.equal(stickyResult.content, 'http-recovered');
|
|
399
|
+
assert.equal(result.usage.inputTokens, 30, 'fallback bills the completed WS warmup once');
|
|
400
|
+
assert.equal(result.usage.mainInputTokens, 20, 'fallback context remains HTTP-main-only');
|
|
401
|
+
assert.equal(result.usage.warmupInputTokens, 10);
|
|
402
|
+
assert.equal(stickyResult.usage.inputTokens, 20, 'sticky HTTP sends do not rebill the old warmup');
|
|
403
|
+
assert.equal(stickyResult.usage.warmupInputTokens, undefined);
|
|
404
|
+
const session = { provider: 'openai-oauth' };
|
|
405
|
+
applyAskTerminalUsageTotals(session, { usage: result.usage, lastTurnUsage: result.usage });
|
|
406
|
+
assert.equal(session.totalInputTokens, 30, 'lifetime billing includes WS warmup plus HTTP main once');
|
|
407
|
+
assert.equal(session.lastContextTokens, 20, 'last context remains HTTP-main-only');
|
|
348
408
|
assert.equal(provider._httpFallbackUntilByPoolKey.get(sendOpts.sessionId), Number.POSITIVE_INFINITY);
|
|
349
409
|
} finally {
|
|
350
410
|
for (const [name, value] of Object.entries(savedEnv)) {
|
|
@@ -12,6 +12,7 @@ import test from 'node:test';
|
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
13
|
import { EventEmitter } from 'node:events';
|
|
14
14
|
import { _streamResponse } from '../src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs';
|
|
15
|
+
import { classifyMidstreamError, MIDSTREAM_RETRY_POLICY } from '../src/runtime/agent/orchestrator/providers/retry-classifier.mjs';
|
|
15
16
|
|
|
16
17
|
// Minimal fake WS: matches the .on/.off/.close/.ping surface _streamResponse
|
|
17
18
|
// uses. close() is a no-op here -- the early-settle path sets done=true before
|
|
@@ -174,3 +175,42 @@ test('ws early settle: function args.done without item.done does NOT early-settl
|
|
|
174
175
|
await assert.rejects(p, /inter-chunk inactivity/);
|
|
175
176
|
assert.equal(socket.closed?.reason, 'inter_chunk_timeout', 'function item must stay active until output_item.done');
|
|
176
177
|
});
|
|
178
|
+
|
|
179
|
+
test('OpenAI WS rejects an oversized Buffer before UTF-8 conversion and marks it retryable', async () => {
|
|
180
|
+
const socket = new FakeSocket();
|
|
181
|
+
const state = { attemptIndex: 0 };
|
|
182
|
+
const payload = Buffer.alloc(33);
|
|
183
|
+
payload.toString = () => { throw new Error('oversized Buffer was decoded'); };
|
|
184
|
+
const p = _streamResponse({
|
|
185
|
+
entry: { socket },
|
|
186
|
+
state,
|
|
187
|
+
_timeouts: { ...FAST_TIMEOUTS, maxIncomingFrameBytes: 32 },
|
|
188
|
+
});
|
|
189
|
+
socket.emit('message', payload);
|
|
190
|
+
await assert.rejects(p, (error) => {
|
|
191
|
+
assert.equal(error.code, 'EOPENAIWSFRAMETOOLARGE');
|
|
192
|
+
assert.equal(error.retryable, true);
|
|
193
|
+
assert.match(error.message, /33 bytes; limit 32 bytes.*retryable/);
|
|
194
|
+
assert.equal(
|
|
195
|
+
classifyMidstreamError(error, state, MIDSTREAM_RETRY_POLICY.ws),
|
|
196
|
+
'ws_frame_too_large',
|
|
197
|
+
);
|
|
198
|
+
return true;
|
|
199
|
+
});
|
|
200
|
+
assert.deepEqual(socket.closed, { code: 1009, reason: 'frame_too_large' });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('OpenAI WS preserves normal-size Buffer handling', async () => {
|
|
204
|
+
const socket = new FakeSocket();
|
|
205
|
+
const p = _streamResponse({
|
|
206
|
+
entry: { socket },
|
|
207
|
+
state: {},
|
|
208
|
+
_timeouts: { ...FAST_TIMEOUTS, maxIncomingFrameBytes: 1024 },
|
|
209
|
+
});
|
|
210
|
+
socket.emit('message', Buffer.from(JSON.stringify({
|
|
211
|
+
type: 'response.completed',
|
|
212
|
+
response: { id: 'resp_normal', model: 'gpt-5.5', output: [] },
|
|
213
|
+
})));
|
|
214
|
+
const result = await p;
|
|
215
|
+
assert.equal(result.responseId, 'resp_normal');
|
|
216
|
+
});
|
|
@@ -6,6 +6,10 @@ import {
|
|
|
6
6
|
markSessionCancelled,
|
|
7
7
|
markSessionError,
|
|
8
8
|
} from '../src/runtime/agent/orchestrator/session/manager.mjs';
|
|
9
|
+
import {
|
|
10
|
+
_clearSessionRuntime,
|
|
11
|
+
_sweepTerminalSessionRuntimes,
|
|
12
|
+
} from '../src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs';
|
|
9
13
|
|
|
10
14
|
test('markSessionError drops parent AbortSignal listener but keeps runtime entry', () => {
|
|
11
15
|
const sessionId = `parent-abort-error-${Date.now()}`;
|
|
@@ -64,3 +68,23 @@ test('markSessionCancelled drops parent AbortSignal listener but keeps runtime e
|
|
|
64
68
|
assert.equal(childAborted, false);
|
|
65
69
|
});
|
|
66
70
|
|
|
71
|
+
test('terminal runtime sweep drains the full backlog but preserves in-flight controllers', () => {
|
|
72
|
+
const prefix = `terminal-retention-${Date.now()}`;
|
|
73
|
+
const terminalIds = Array.from({ length: 40 }, (_, i) => `${prefix}-${i}`);
|
|
74
|
+
for (const id of terminalIds) {
|
|
75
|
+
linkParentSignalToSession(id, new AbortController().signal);
|
|
76
|
+
markSessionError(id, 'finished');
|
|
77
|
+
getSessionRuntime(id).controller = null;
|
|
78
|
+
}
|
|
79
|
+
const activeId = `${prefix}-active`;
|
|
80
|
+
linkParentSignalToSession(activeId, new AbortController().signal);
|
|
81
|
+
markSessionError(activeId, 'provider still unwinding');
|
|
82
|
+
|
|
83
|
+
const cleaned = _sweepTerminalSessionRuntimes();
|
|
84
|
+
|
|
85
|
+
assert.ok(cleaned >= terminalIds.length, 'one pass drains every settled terminal entry');
|
|
86
|
+
for (const id of terminalIds) assert.equal(getSessionRuntime(id), null);
|
|
87
|
+
assert.ok(getSessionRuntime(activeId)?.controller, 'active in-flight controller is retained');
|
|
88
|
+
_clearSessionRuntime(activeId);
|
|
89
|
+
});
|
|
90
|
+
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
mkdirSync,
|
|
5
5
|
mkdtempSync,
|
|
6
6
|
readFileSync,
|
|
7
|
+
readdirSync,
|
|
7
8
|
rmSync,
|
|
8
9
|
unlinkSync,
|
|
9
10
|
utimesSync,
|
|
@@ -103,71 +104,107 @@ test('vanished evidence uses only old-process fields and both ledger files stay
|
|
|
103
104
|
}
|
|
104
105
|
});
|
|
105
106
|
|
|
106
|
-
test('PID reuse is detected by process identity while uncertain live identity is preserved', () => {
|
|
107
|
+
test('PID reuse is detected by process identity while uncertain live identity is preserved', async () => {
|
|
107
108
|
const root = tempRoot();
|
|
109
|
+
let child;
|
|
108
110
|
try {
|
|
109
111
|
const paths = lifecyclePathsForTest(root);
|
|
110
112
|
mkdirSync(paths.markerDir, { recursive: true });
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
child = spawn(process.execPath, ['--input-type=module', '--eval', `
|
|
114
|
+
import { beginProcessLifecycle } from './src/runtime/shared/process-lifecycle.mjs';
|
|
115
|
+
beginProcessLifecycle({ directory: process.env.LEDGER_DIR, configureReports: false });
|
|
116
|
+
setTimeout(() => {}, 10000);
|
|
117
|
+
`], {
|
|
118
|
+
cwd: REPO_ROOT,
|
|
119
|
+
env: { ...process.env, LEDGER_DIR: root },
|
|
120
|
+
stdio: 'ignore',
|
|
121
|
+
});
|
|
122
|
+
let childMarker;
|
|
123
|
+
let currentIdentity;
|
|
124
|
+
const deadline = Date.now() + 5000;
|
|
125
|
+
while (Date.now() < deadline) {
|
|
126
|
+
const name = readdirSync(paths.markerDir).find((entry) => entry.startsWith(`${child.pid}-`));
|
|
127
|
+
if (name) {
|
|
128
|
+
childMarker = join(paths.markerDir, name);
|
|
129
|
+
currentIdentity = JSON.parse(readFileSync(childMarker, 'utf8')).processIdentity;
|
|
130
|
+
if (currentIdentity?.kind === 'linux-start-ticks' || currentIdentity?.method === 'powershell') break;
|
|
131
|
+
}
|
|
132
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
133
|
+
}
|
|
113
134
|
assert.ok(currentIdentity);
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
const
|
|
135
|
+
if (process.platform === 'win32') assert.equal(currentIdentity.method, 'powershell');
|
|
136
|
+
|
|
137
|
+
const reused = join(paths.markerDir, `${child.pid}-reused.json`);
|
|
138
|
+
const matching = join(paths.markerDir, `${child.pid}-matching.json`);
|
|
139
|
+
const uncertain = join(paths.markerDir, `${child.pid}-uncertain.json`);
|
|
140
|
+
const malformed = join(paths.markerDir, `${child.pid}-malformed.json`);
|
|
141
|
+
const clockAmbiguous = join(paths.markerDir, `${child.pid}-clock.json`);
|
|
142
|
+
const differentKind = join(paths.markerDir, `${child.pid}-different-kind.json`);
|
|
143
|
+
const mixedTransient = join(paths.markerDir, `${child.pid}-mixed-transient.json`);
|
|
144
|
+
const samePidForeign = join(paths.markerDir, `${process.pid}-same-pid-foreign.json`);
|
|
121
145
|
const reusedIdentity = currentIdentity.kind === 'linux-start-ticks'
|
|
122
146
|
? { kind: currentIdentity.kind, value: String(BigInt(currentIdentity.value) + 1n) }
|
|
123
|
-
: {
|
|
147
|
+
: { ...currentIdentity, value: currentIdentity.value + 1 };
|
|
124
148
|
writeFileSync(reused, JSON.stringify({
|
|
125
|
-
pid:
|
|
126
|
-
ppid: process.ppid,
|
|
149
|
+
pid: child.pid,
|
|
127
150
|
token: 'reused',
|
|
128
151
|
processIdentity: reusedIdentity,
|
|
129
152
|
}));
|
|
130
153
|
writeFileSync(matching, JSON.stringify({
|
|
131
|
-
pid:
|
|
154
|
+
pid: child.pid,
|
|
132
155
|
token: 'matching',
|
|
133
156
|
processIdentity: currentIdentity,
|
|
134
157
|
}));
|
|
135
158
|
writeFileSync(uncertain, JSON.stringify({
|
|
136
|
-
pid:
|
|
137
|
-
ppid: process.ppid,
|
|
159
|
+
pid: child.pid,
|
|
138
160
|
token: 'uncertain',
|
|
139
161
|
}));
|
|
140
162
|
writeFileSync(malformed, JSON.stringify({
|
|
141
|
-
pid:
|
|
142
|
-
ppid: process.ppid,
|
|
163
|
+
pid: child.pid,
|
|
143
164
|
token: 'malformed',
|
|
144
165
|
processIdentity: process.platform === 'linux'
|
|
145
166
|
? { kind: 'linux-start-ticks', value: 'not-a-number' }
|
|
146
167
|
: { kind: 'start-seconds', value: 'not-a-number' },
|
|
147
168
|
}));
|
|
148
169
|
writeFileSync(clockAmbiguous, JSON.stringify({
|
|
149
|
-
pid:
|
|
150
|
-
ppid: process.ppid,
|
|
170
|
+
pid: child.pid,
|
|
151
171
|
token: 'clock-adjusted',
|
|
152
172
|
processIdentity: { kind: 'legacy-wall-clock', value: Date.now() + 86400000 },
|
|
153
173
|
}));
|
|
154
174
|
writeFileSync(differentKind, JSON.stringify({
|
|
155
|
-
pid:
|
|
175
|
+
pid: child.pid,
|
|
156
176
|
token: 'different-kind',
|
|
157
177
|
processIdentity: currentIdentity.kind === 'linux-start-ticks'
|
|
158
178
|
? { kind: 'start-seconds', value: 1 }
|
|
159
179
|
: { kind: 'linux-start-ticks', value: '1' },
|
|
160
180
|
}));
|
|
181
|
+
if (currentIdentity.kind === 'start-seconds') {
|
|
182
|
+
writeFileSync(mixedTransient, JSON.stringify({
|
|
183
|
+
pid: child.pid,
|
|
184
|
+
token: 'mixed-transient',
|
|
185
|
+
processIdentity: { kind: currentIdentity.kind, value: currentIdentity.value + 1, method: 'uptime' },
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
writeFileSync(samePidForeign, JSON.stringify({
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
token: 'same-pid-foreign',
|
|
191
|
+
processIdentity: currentIdentity,
|
|
192
|
+
}));
|
|
161
193
|
beginProcessLifecycle({ directory: root, configureReports: false });
|
|
162
194
|
finishProcessLifecycle('clean-shutdown', 0);
|
|
163
|
-
assert.equal(entries(paths.ledger).filter((row) => row.reason === 'prior-process-vanished').length,
|
|
195
|
+
assert.equal(entries(paths.ledger).filter((row) => row.reason === 'prior-process-vanished').length, 2);
|
|
164
196
|
assert.equal(existsSync(reused), false);
|
|
165
197
|
assert.equal(existsSync(matching), true);
|
|
166
198
|
assert.equal(existsSync(uncertain), true);
|
|
167
199
|
assert.equal(existsSync(malformed), true);
|
|
168
200
|
assert.equal(existsSync(clockAmbiguous), true);
|
|
169
201
|
assert.equal(existsSync(differentKind), true);
|
|
202
|
+
assert.equal(existsSync(samePidForeign), false);
|
|
203
|
+
assert.equal(existsSync(childMarker), true);
|
|
204
|
+
if (currentIdentity.kind === 'start-seconds') assert.equal(existsSync(mixedTransient), true);
|
|
170
205
|
} finally {
|
|
206
|
+
child?.kill();
|
|
207
|
+
if (child) await childExit(child);
|
|
171
208
|
rmSync(root, { recursive: true, force: true });
|
|
172
209
|
}
|
|
173
210
|
});
|
|
@@ -315,6 +352,27 @@ test('cleanup rejection and timeout are forced cleanup, not clean shutdown', asy
|
|
|
315
352
|
}
|
|
316
353
|
});
|
|
317
354
|
|
|
355
|
+
test('uncaughtException synchronously restores terminal modes before cleanup', () => {
|
|
356
|
+
const source = `
|
|
357
|
+
import { installProcessSignalCleanup } from './src/runtime/shared/process-shutdown.mjs';
|
|
358
|
+
const reset = '\\x1b[?1000l\\x1b[?1002l\\x1b[?1006l\\x1b[?1049l';
|
|
359
|
+
installProcessSignalCleanup({
|
|
360
|
+
signals: [],
|
|
361
|
+
exit: false,
|
|
362
|
+
restoreTerminal: () => process.stdout.write(reset),
|
|
363
|
+
cleanup: async () => {},
|
|
364
|
+
log: () => {},
|
|
365
|
+
});
|
|
366
|
+
throw new Error('terminal fixture crash');
|
|
367
|
+
`;
|
|
368
|
+
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', source], {
|
|
369
|
+
cwd: REPO_ROOT,
|
|
370
|
+
encoding: 'utf8',
|
|
371
|
+
});
|
|
372
|
+
assert.equal(result.status, 0, result.stderr);
|
|
373
|
+
assert.equal(result.stdout, '\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1049l');
|
|
374
|
+
});
|
|
375
|
+
|
|
318
376
|
test('Node report is compact, excludes environment, and rotates to one previous report', () => {
|
|
319
377
|
const root = tempRoot();
|
|
320
378
|
try {
|