mixdog 0.9.33 → 0.9.35
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/README.md +6 -0
- package/package.json +7 -3
- package/scripts/_devtools-stub.mjs +1 -0
- package/scripts/_jitter-fuzz.jsx +42 -0
- package/scripts/_jitter-fuzz.mjs +44410 -0
- package/scripts/_jitter-fuzz2.jsx +30 -0
- package/scripts/_jitter-fuzz2.mjs +44400 -0
- package/scripts/_jitter-probe.jsx +35 -0
- package/scripts/_jitter-probe.mjs +44397 -0
- package/scripts/_jp2.jsx +16 -0
- package/scripts/_jp2.mjs +45614 -0
- package/scripts/agent-live-arg-guard-smoke.mjs +20 -0
- package/scripts/agent-live-path-suffix-smoke.mjs +34 -0
- package/scripts/agent-live-toolcall-args-smoke.mjs +45 -0
- package/scripts/apply-patch-edit-smoke.mjs +71 -0
- package/scripts/async-notify-settlement-test.mjs +99 -0
- package/scripts/compact-trigger-migration-smoke.mjs +37 -31
- package/scripts/forwarder-rebind-tail-test.mjs +67 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/pending-completion-drop-test.mjs +46 -27
- package/scripts/provider-toolcall-test.mjs +536 -36
- package/scripts/recall-bench-cases.json +1 -0
- package/scripts/session-bench.mjs +44 -0
- package/scripts/shell-hardening-test.mjs +209 -0
- package/scripts/ship-mode-test.mjs +98 -0
- package/scripts/steering-drain-buckets-test.mjs +59 -0
- package/scripts/tool-smoke.mjs +46 -10
- package/scripts/worker-notify-rejection-test.mjs +51 -0
- package/src/agents/heavy-worker/AGENT.md +3 -4
- package/src/agents/worker/AGENT.md +2 -3
- package/src/lib/mixdog-debug.cjs +69 -0
- package/src/repl.mjs +9 -1
- package/src/rules/agent/00-common.md +4 -5
- package/src/rules/agent/00-core.md +13 -20
- package/src/rules/agent/20-skip-protocol.md +3 -8
- package/src/rules/agent/30-explorer.md +31 -54
- package/src/rules/lead/01-general.md +3 -6
- package/src/rules/lead/lead-brief.md +10 -16
- package/src/rules/lead/lead-tool.md +3 -5
- package/src/rules/shared/01-tool.md +11 -11
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +13 -2
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +22 -0
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +21 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +21 -24
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +21 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -12
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +131 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +102 -26
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +194 -9
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -1
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/compact.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +39 -6
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +15 -23
- package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +25 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +27 -19
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +21 -7
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +50 -4
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +53 -1
- package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +1 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +83 -3
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +17 -9
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +127 -4
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +105 -28
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +270 -47
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +7 -3
- package/src/runtime/channels/lib/output-forwarder.mjs +7 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +5 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +18 -3
- package/src/runtime/memory/lib/memory-embed.mjs +89 -8
- package/src/runtime/memory/lib/query-handlers.mjs +25 -10
- package/src/runtime/memory/tool-defs.mjs +3 -3
- package/src/runtime/shared/background-tasks.mjs +21 -1
- package/src/runtime/shared/tool-execution-contract.mjs +29 -16
- package/src/session-runtime/context-status.mjs +7 -6
- package/src/session-runtime/model-route-api.mjs +3 -3
- package/src/session-runtime/provider-auth-api.mjs +13 -0
- package/src/session-runtime/runtime-core.mjs +40 -16
- package/src/session-runtime/tool-catalog.mjs +3 -2
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/notify.mjs +14 -1
- package/src/tui/app/use-transcript-window.mjs +13 -1
- package/src/tui/components/StatusLine.jsx +5 -5
- package/src/tui/dist/index.mjs +68 -28
- package/src/tui/engine/session-flow.mjs +26 -13
- package/src/tui/engine/turn.mjs +7 -8
- package/src/tui/engine.mjs +4 -2
- package/src/ui/statusline.mjs +4 -10
- package/src/vendor/statusline/bin/statusline-route.mjs +64 -9
- package/src/vendor/statusline/src/gateway/route-meta.mjs +38 -8
- package/src/workflows/default/WORKFLOW.md +26 -32
- package/src/workflows/solo/WORKFLOW.md +12 -13
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Standalone regression smoke for validateBuiltinArgs guard behavior.
|
|
2
|
+
// Mirrors arg-guard-test.mjs style; runs with `node --test`.
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { validateBuiltinArgs } from '../src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs';
|
|
6
|
+
|
|
7
|
+
test('smoke: valid read args pass and numeric strings coerce', () => {
|
|
8
|
+
const a = { path: 'x.mjs', offset: '2', limit: '10' };
|
|
9
|
+
assert.equal(validateBuiltinArgs('read', a), null);
|
|
10
|
+
assert.equal(a.offset, 2);
|
|
11
|
+
assert.equal(a.limit, 10);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('smoke: non-numeric arg still errors', () => {
|
|
15
|
+
assert.match(validateBuiltinArgs('read', { path: 'x.mjs', limit: 'nope' }), /must be an integer/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('smoke: below-min arg still errors', () => {
|
|
19
|
+
assert.match(validateBuiltinArgs('read', { path: 'x.mjs', offset: -1 }), /must be >= 0/);
|
|
20
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { findBySuffixStrip } from '../src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs';
|
|
7
|
+
|
|
8
|
+
function makeRepo() {
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-suffix-smoke-'));
|
|
10
|
+
mkdirSync(join(root, 'src', 'tui'), { recursive: true });
|
|
11
|
+
writeFileSync(join(root, 'src', 'tui', 'input-editing.mjs'), '// real file\n');
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test('smoke: hallucinated absolute prefix resolves to the real repo-relative file', () => {
|
|
16
|
+
const root = makeRepo();
|
|
17
|
+
try {
|
|
18
|
+
const hallucinated = '/Users/nobody/Elsewhere/Project/ink/src/tui/input-editing.mjs';
|
|
19
|
+
const hit = findBySuffixStrip(root, hallucinated);
|
|
20
|
+
assert.equal(hit, 'src/tui/input-editing.mjs');
|
|
21
|
+
} finally {
|
|
22
|
+
rmSync(root, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('smoke: a non-existent tail resolves to null', () => {
|
|
27
|
+
const root = makeRepo();
|
|
28
|
+
try {
|
|
29
|
+
const hit = findBySuffixStrip(root, '/Users/nobody/Elsewhere/Project/does/not/here.mjs');
|
|
30
|
+
assert.equal(hit, null);
|
|
31
|
+
} finally {
|
|
32
|
+
rmSync(root, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Standalone regression smoke pinning the tool_call arguments contract used by
|
|
3
|
+
// the native providers (see scripts/toolcall-args-test.mjs for the full suite).
|
|
4
|
+
// Same unit-test style: synthetic inputs fed to the exported parser, asserting
|
|
5
|
+
// the outcome — no network, no model. Kept minimal so it can run in isolation
|
|
6
|
+
// via `node --test scripts/agent-live-toolcall-args-smoke.mjs`.
|
|
7
|
+
import test from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import {
|
|
10
|
+
parseCompletedToolCallArgumentsJson,
|
|
11
|
+
isInvalidToolArgsMarker,
|
|
12
|
+
formatInvalidToolArgsResult,
|
|
13
|
+
} from '../src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs';
|
|
14
|
+
|
|
15
|
+
test('smoke: valid arguments JSON parses to the intended object', () => {
|
|
16
|
+
const out = parseCompletedToolCallArgumentsJson(
|
|
17
|
+
'{"pattern":"x","path":"src"}', 'smoke', { finishReason: 'stop' });
|
|
18
|
+
assert.deepEqual(out, { pattern: 'x', path: 'src' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('smoke: empty/missing arguments default to {}', () => {
|
|
22
|
+
assert.deepEqual(parseCompletedToolCallArgumentsJson('', 'smoke', { finishReason: 'stop' }), {});
|
|
23
|
+
assert.deepEqual(parseCompletedToolCallArgumentsJson(undefined, 'smoke'), {});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('smoke: malformed args with finishReason → invalid-args marker (no throw)', () => {
|
|
27
|
+
const bareword = '{"pattern": dispatchAiWrapped, "path": "src/agent"}';
|
|
28
|
+
const out = parseCompletedToolCallArgumentsJson(bareword, 'smoke', { finishReason: 'stop' });
|
|
29
|
+
assert.equal(isInvalidToolArgsMarker(out), true);
|
|
30
|
+
assert.equal(out.__rawArguments, bareword);
|
|
31
|
+
const msg = formatInvalidToolArgsResult({ name: 'grep', arguments: out });
|
|
32
|
+
assert.match(msg, /Re-issue this tool call with valid JSON arguments/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('smoke: malformed args without finishReason → retryable TruncatedStreamError', () => {
|
|
36
|
+
let threw;
|
|
37
|
+
try {
|
|
38
|
+
parseCompletedToolCallArgumentsJson('{', 'smoke');
|
|
39
|
+
} catch (err) {
|
|
40
|
+
threw = err;
|
|
41
|
+
}
|
|
42
|
+
assert.ok(threw instanceof Error, 'must throw on mid-stream truncation');
|
|
43
|
+
assert.equal(threw.code, 'TRUNCATED_STREAM');
|
|
44
|
+
assert.equal(threw.truncatedStream, true);
|
|
45
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { executePatchTool } from '../src/runtime/agent/orchestrator/tools/patch.mjs';
|
|
6
|
+
|
|
7
|
+
function assert(condition, message) {
|
|
8
|
+
if (!condition) throw new Error(message);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assertOk(label, result) {
|
|
12
|
+
const text = String(result || '');
|
|
13
|
+
if (!text || /^Error[\s:]/.test(text)) {
|
|
14
|
+
throw new Error(`${label} failed:\n${text}`);
|
|
15
|
+
}
|
|
16
|
+
return text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const tmp = mkdtempSync(join(tmpdir(), 'mixdog-apply-patch-smoke-'));
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
writeFileSync(join(tmp, 'target.txt'), 'alpha\nbeta\ngamma\n', 'utf8');
|
|
23
|
+
|
|
24
|
+
const editResult = await executePatchTool('apply_patch', {
|
|
25
|
+
base_path: tmp,
|
|
26
|
+
patch: `*** Begin Patch
|
|
27
|
+
*** Update File: target.txt
|
|
28
|
+
@@
|
|
29
|
+
alpha
|
|
30
|
+
-beta
|
|
31
|
+
+bravo
|
|
32
|
+
gamma
|
|
33
|
+
*** Add File: created.txt
|
|
34
|
+
+created by apply_patch smoke
|
|
35
|
+
+second line
|
|
36
|
+
*** End Patch
|
|
37
|
+
`,
|
|
38
|
+
}, tmp, {});
|
|
39
|
+
assertOk('apply_patch edit', editResult);
|
|
40
|
+
|
|
41
|
+
assert(
|
|
42
|
+
readFileSync(join(tmp, 'target.txt'), 'utf8') === 'alpha\nbravo\ngamma\n',
|
|
43
|
+
'apply_patch update did not write the expected target.txt contents',
|
|
44
|
+
);
|
|
45
|
+
assert(
|
|
46
|
+
readFileSync(join(tmp, 'created.txt'), 'utf8') === 'created by apply_patch smoke\nsecond line\n',
|
|
47
|
+
'apply_patch add did not write the expected created.txt contents',
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const deleteResult = await executePatchTool('apply_patch', {
|
|
51
|
+
base_path: tmp,
|
|
52
|
+
patch: `*** Begin Patch
|
|
53
|
+
*** Delete File: created.txt
|
|
54
|
+
*** End Patch
|
|
55
|
+
`,
|
|
56
|
+
}, tmp, {});
|
|
57
|
+
assertOk('apply_patch delete', deleteResult);
|
|
58
|
+
|
|
59
|
+
let deleteMissing = false;
|
|
60
|
+
try {
|
|
61
|
+
readFileSync(join(tmp, 'created.txt'), 'utf8');
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err?.code === 'ENOENT') deleteMissing = true;
|
|
64
|
+
else throw err;
|
|
65
|
+
}
|
|
66
|
+
assert(deleteMissing, 'apply_patch delete left created.txt on disk');
|
|
67
|
+
|
|
68
|
+
process.stdout.write('apply_patch edit smoke passed\n');
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
71
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// A Promise-returning notifyFn must only *mark* a background-task completion
|
|
2
|
+
// delivered after the promise settles. When it rejects/declines AND the
|
|
3
|
+
// enqueueFallback rescue also fails, the task must be left UN-marked so a later
|
|
4
|
+
// reconcile can retry — never silently marked delivered before settlement.
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
registerBackgroundTask,
|
|
10
|
+
completeBackgroundTask,
|
|
11
|
+
getBackgroundTask,
|
|
12
|
+
setBackgroundTaskEnqueueFallback,
|
|
13
|
+
reconcileBackgroundTask,
|
|
14
|
+
} from '../src/runtime/shared/background-tasks.mjs';
|
|
15
|
+
|
|
16
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
17
|
+
|
|
18
|
+
test('async notifyFn rejection with no rescue does not mark delivered', async () => {
|
|
19
|
+
setBackgroundTaskEnqueueFallback(null); // no fallback channel → nothing lands
|
|
20
|
+
const task = registerBackgroundTask({
|
|
21
|
+
surface: 'tool',
|
|
22
|
+
operation: 'run',
|
|
23
|
+
resultType: 'tool_task_result',
|
|
24
|
+
context: { notifyFn: () => Promise.reject(new Error('boom')) },
|
|
25
|
+
});
|
|
26
|
+
completeBackgroundTask(task.taskId, { status: 'completed', resultText: 'body payload', terminalReason: 'test' });
|
|
27
|
+
|
|
28
|
+
// Optimistic marks were applied synchronously; settlement must clear them.
|
|
29
|
+
await tick();
|
|
30
|
+
await tick();
|
|
31
|
+
const t = getBackgroundTask(task.taskId);
|
|
32
|
+
assert.equal(t.notifiedWithBody, false, 'body delivery un-marked after async failure');
|
|
33
|
+
assert.equal(t.notified, false, 'notified un-marked after async failure');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('async notifyFn success keeps the completion marked delivered', async () => {
|
|
37
|
+
setBackgroundTaskEnqueueFallback(null);
|
|
38
|
+
const task = registerBackgroundTask({
|
|
39
|
+
surface: 'tool',
|
|
40
|
+
operation: 'run',
|
|
41
|
+
resultType: 'tool_task_result',
|
|
42
|
+
context: { notifyFn: () => Promise.resolve(true) },
|
|
43
|
+
});
|
|
44
|
+
completeBackgroundTask(task.taskId, { status: 'completed', resultText: 'body payload', terminalReason: 'test' });
|
|
45
|
+
|
|
46
|
+
await tick();
|
|
47
|
+
await tick();
|
|
48
|
+
const t = getBackgroundTask(task.taskId);
|
|
49
|
+
assert.equal(t.notifiedWithBody, true, 'successful async delivery stays marked');
|
|
50
|
+
assert.equal(t.notified, true, 'successful async delivery stays notified');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('async notifyFn rejection is rescued by fallback and stays marked', async () => {
|
|
54
|
+
const enqueued = [];
|
|
55
|
+
setBackgroundTaskEnqueueFallback((sessionId, text) => { enqueued.push({ sessionId, text }); return 1; });
|
|
56
|
+
const task = registerBackgroundTask({
|
|
57
|
+
surface: 'tool',
|
|
58
|
+
operation: 'run',
|
|
59
|
+
resultType: 'tool_task_result',
|
|
60
|
+
context: { notifyFn: () => Promise.reject(new Error('boom')), callerSessionId: 'sess_rescue' },
|
|
61
|
+
});
|
|
62
|
+
completeBackgroundTask(task.taskId, { status: 'completed', resultText: 'body payload', terminalReason: 'test' });
|
|
63
|
+
|
|
64
|
+
await tick();
|
|
65
|
+
await tick();
|
|
66
|
+
const t = getBackgroundTask(task.taskId);
|
|
67
|
+
assert.equal(enqueued.length, 1, 'fallback rescued the completion once');
|
|
68
|
+
assert.equal(t.notifiedWithBody, true, 'rescued delivery stays marked exactly once');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('reconcile retries an un-marked completion on an already-terminal task', async () => {
|
|
72
|
+
// First completion: async notifyFn rejects and there is NO fallback, so the
|
|
73
|
+
// task is driven terminal but the body notification un-marks itself.
|
|
74
|
+
setBackgroundTaskEnqueueFallback(null);
|
|
75
|
+
const task = registerBackgroundTask({
|
|
76
|
+
surface: 'tool',
|
|
77
|
+
operation: 'run',
|
|
78
|
+
resultType: 'tool_task_result',
|
|
79
|
+
context: { notifyFn: () => Promise.reject(new Error('boom')), callerSessionId: 'sess_reconcile' },
|
|
80
|
+
});
|
|
81
|
+
completeBackgroundTask(task.taskId, { status: 'completed', resultText: 'body payload', terminalReason: 'test' });
|
|
82
|
+
await tick();
|
|
83
|
+
await tick();
|
|
84
|
+
const before = getBackgroundTask(task.taskId);
|
|
85
|
+
assert.equal(before.notifiedWithBody, false, 'body un-marked after the failed async notify');
|
|
86
|
+
|
|
87
|
+
// A later reconcile on the (already-terminal) task must retry the delivery
|
|
88
|
+
// rather than returning early. With a working fallback now in place it lands.
|
|
89
|
+
const enqueued = [];
|
|
90
|
+
setBackgroundTaskEnqueueFallback((sessionId, text) => { enqueued.push({ sessionId, text }); return 1; });
|
|
91
|
+
reconcileBackgroundTask(task.taskId, { status: 'completed', terminalReason: 'reconciled' });
|
|
92
|
+
await tick();
|
|
93
|
+
await tick();
|
|
94
|
+
const after = getBackgroundTask(task.taskId);
|
|
95
|
+
assert.equal(enqueued.length, 1, 'reconcile re-fired the completion once on the terminal task');
|
|
96
|
+
assert.equal(after.notifiedWithBody, true, 'reconcile retry marks the body delivered');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test.after(() => { setBackgroundTaskEnqueueFallback(null); });
|
|
@@ -55,52 +55,58 @@ function assert(condition, message) {
|
|
|
55
55
|
`sub-boundary explicit limit should be preserved, got ${meta.autoCompactTokenLimit}`);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// 3) compactTriggerForSession
|
|
59
|
-
//
|
|
58
|
+
// 3) compactTriggerForSession: main/user (non-agent) recall-fasttrack compacts
|
|
59
|
+
// on the boundary itself (100%, no early-trigger buffer); a truly-explicit
|
|
60
|
+
// sub-boundary autoCompactTokenLimit still wins. Agent-owned semantic
|
|
61
|
+
// sessions keep the default 10% buffer (90%).
|
|
60
62
|
{
|
|
61
63
|
const boundary = 200000;
|
|
62
64
|
const legacy = compactTriggerForSession({ autoCompactTokenLimit: boundary, compaction: {} }, boundary);
|
|
63
|
-
assert(legacy ===
|
|
65
|
+
assert(legacy === 200000, `main/user boundary-equal limit should yield 100% trigger 200000, got ${legacy}`);
|
|
64
66
|
const explicit = compactTriggerForSession({ autoCompactTokenLimit: 150000, compaction: {} }, boundary);
|
|
65
67
|
assert(explicit === 150000, `sub-boundary explicit limit should be the trigger, got ${explicit}`);
|
|
68
|
+
const agent = compactTriggerForSession({ owner: 'agent', autoCompactTokenLimit: boundary, compaction: {} }, boundary);
|
|
69
|
+
assert(agent === 180000, `agent boundary-equal limit should yield default 10% trigger 180000, got ${agent}`);
|
|
70
|
+
const agentExplicit = compactTriggerForSession({ owner: 'agent', autoCompactTokenLimit: 150000, compaction: {} }, boundary);
|
|
71
|
+
assert(agentExplicit === 150000, `agent sub-boundary explicit limit should be the trigger, got ${agentExplicit}`);
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
// 4) A configured bufferPercent flows into the trigger
|
|
74
|
+
// 4) A configured bufferPercent flows into the trigger for agent-owned sessions;
|
|
75
|
+
// main/user sessions ignore the buffer and stay on the boundary (100%).
|
|
69
76
|
{
|
|
70
77
|
const boundary = 200000;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
const agentTrigger = compactTriggerForSession({ owner: 'agent', compaction: { bufferPercent: 5 } }, boundary);
|
|
79
|
+
assert(agentTrigger === 190000, `agent bufferPercent 5 should yield trigger 190000, got ${agentTrigger}`);
|
|
80
|
+
const userTrigger = compactTriggerForSession({ compaction: { bufferPercent: 5 } }, boundary);
|
|
81
|
+
assert(userTrigger === 200000, `main/user should ignore bufferPercent and stay at 100% 200000, got ${userTrigger}`);
|
|
74
82
|
}
|
|
75
83
|
|
|
76
|
-
// 5) Legacy telemetry
|
|
77
|
-
//
|
|
78
|
-
//
|
|
84
|
+
// 5) Legacy/zero buffer telemetry migration is an agent-path concern (the
|
|
85
|
+
// default-buffer sanitizer). Agent-owned sessions reapply the current 10%
|
|
86
|
+
// default (trigger 180000); explicit bufferTokens still lowers the agent
|
|
87
|
+
// trigger. Main/user sessions ignore all of it and compact at 100%.
|
|
79
88
|
{
|
|
80
89
|
const boundary = 200000;
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
triggerTokens: 180000,
|
|
85
|
-
bufferTokens: 20000,
|
|
86
|
-
bufferRatio: 0.1,
|
|
87
|
-
},
|
|
90
|
+
const agentLegacy = compactTriggerForSession({
|
|
91
|
+
owner: 'agent',
|
|
92
|
+
compaction: { boundaryTokens: boundary, triggerTokens: 180000, bufferTokens: 20000, bufferRatio: 0.1 },
|
|
88
93
|
}, boundary);
|
|
89
|
-
assert(
|
|
90
|
-
`legacy default buffer telemetry should yield 10% headroom trigger 180000, got ${
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
triggerTokens: boundary,
|
|
95
|
-
bufferTokens: 0,
|
|
96
|
-
bufferRatio: 0,
|
|
97
|
-
},
|
|
94
|
+
assert(agentLegacy === 180000,
|
|
95
|
+
`agent legacy default buffer telemetry should yield 10% headroom trigger 180000, got ${agentLegacy}`);
|
|
96
|
+
const agentZero = compactTriggerForSession({
|
|
97
|
+
owner: 'agent',
|
|
98
|
+
compaction: { boundaryTokens: boundary, triggerTokens: boundary, bufferTokens: 0, bufferRatio: 0 },
|
|
98
99
|
}, boundary);
|
|
99
|
-
assert(
|
|
100
|
-
`persisted zero-buffer telemetry should restore default 10% trigger 180000, got ${
|
|
101
|
-
const
|
|
102
|
-
assert(
|
|
103
|
-
`explicit bufferTokens should
|
|
100
|
+
assert(agentZero === 180000,
|
|
101
|
+
`agent persisted zero-buffer telemetry should restore default 10% trigger 180000, got ${agentZero}`);
|
|
102
|
+
const agentExplicitTokens = compactTriggerForSession({ owner: 'agent', compaction: { bufferTokens: 10000 } }, boundary);
|
|
103
|
+
assert(agentExplicitTokens === 190000,
|
|
104
|
+
`agent explicit bufferTokens should lower trigger to 190000, got ${agentExplicitTokens}`);
|
|
105
|
+
const userTelemetry = compactTriggerForSession({
|
|
106
|
+
compaction: { boundaryTokens: boundary, triggerTokens: 180000, bufferTokens: 20000, bufferRatio: 0.1 },
|
|
107
|
+
}, boundary);
|
|
108
|
+
assert(userTelemetry === 200000,
|
|
109
|
+
`main/user should ignore buffer telemetry and stay at 100% 200000, got ${userTelemetry}`);
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
// 6) preserveBufferConfigFields copies only finite-positive percent/ratio fields.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Regression: on channel connect/change/rebind, OutputForwarder.setContext must
|
|
2
|
+
// NOT pull the read cursor back to a prior/unrelated transcript's tail. Only a
|
|
3
|
+
// genuinely-unsynced tail of the SAME transcript (crash recovery) is recovered;
|
|
4
|
+
// a fresh binding to a DIFFERENT transcript stays at EOF so only outputs created
|
|
5
|
+
// after the new binding are forwarded (never the old tail).
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { OutputForwarder } from '../src/runtime/channels/lib/output-forwarder.mjs';
|
|
12
|
+
|
|
13
|
+
function jsonl(...entries) {
|
|
14
|
+
return entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const USER = { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } };
|
|
18
|
+
const ASSISTANT = { type: 'assistant', message: { content: [{ type: 'text', text: 'old reply tail' }] } };
|
|
19
|
+
|
|
20
|
+
function makeForwarder(persisted) {
|
|
21
|
+
// statusState stub: setContext only reads via this.statusState.read().
|
|
22
|
+
return new OutputForwarder(() => {}, { read: () => persisted });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('rebind to a DIFFERENT transcript never recovers the old tail (cursor stays at EOF)', () => {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), 'fwd-rebind-'));
|
|
27
|
+
try {
|
|
28
|
+
const bound = join(dir, 'new-session.jsonl');
|
|
29
|
+
const prior = join(dir, 'prior-session.jsonl');
|
|
30
|
+
writeFileSync(bound, jsonl(USER, ASSISTANT));
|
|
31
|
+
const size = statSync(bound).size;
|
|
32
|
+
// Persisted status points at a DIFFERENT (prior) transcript, no send evidence.
|
|
33
|
+
const fwd = makeForwarder({
|
|
34
|
+
transcriptPath: prior,
|
|
35
|
+
lastFileSize: 0,
|
|
36
|
+
sentCount: 0,
|
|
37
|
+
lastSentTime: 0,
|
|
38
|
+
lastSentHash: '',
|
|
39
|
+
});
|
|
40
|
+
fwd.setContext('chan-1', bound, { catchUpFromPersisted: true, recoverUnsyncedTail: true });
|
|
41
|
+
assert.equal(fwd.lastFileSize, size, 'cursor must be at EOF — old tail of a fresh binding is not forwarded');
|
|
42
|
+
assert.equal(fwd.readFileSize, size);
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('SAME-transcript unsynced tail (no send evidence) is still recovered', () => {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), 'fwd-same-'));
|
|
50
|
+
try {
|
|
51
|
+
const bound = join(dir, 'session.jsonl');
|
|
52
|
+
writeFileSync(bound, jsonl(USER, ASSISTANT));
|
|
53
|
+
const size = statSync(bound).size;
|
|
54
|
+
// Persisted status is the SAME transcript, fully synced size, no send evidence.
|
|
55
|
+
const fwd = makeForwarder({
|
|
56
|
+
transcriptPath: bound,
|
|
57
|
+
lastFileSize: size,
|
|
58
|
+
sentCount: 0,
|
|
59
|
+
lastSentTime: 0,
|
|
60
|
+
lastSentHash: '',
|
|
61
|
+
});
|
|
62
|
+
fwd.setContext('chan-1', bound, { catchUpFromPersisted: true, recoverUnsyncedTail: true });
|
|
63
|
+
assert.ok(fwd.lastFileSize < size, 'same-session unsynced tail must be recovered (cursor before EOF)');
|
|
64
|
+
} finally {
|
|
65
|
+
rmSync(dir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
@@ -37,7 +37,7 @@ async function main() {
|
|
|
37
37
|
const workflowRules = readFileSync('src/workflows/default/WORKFLOW.md', 'utf8');
|
|
38
38
|
assert(/Use `agent` for scoped implementation/i.test(leadToolRules), 'lead rules must direct scoped work to agents');
|
|
39
39
|
assert(/PARALLEL across independent scopes/i.test(workflowRules), 'workflow rules must keep independent work parallel');
|
|
40
|
-
assert(/always start background tasks/i.test(AGENT_TOOL.description || '') && /distinct tags
|
|
40
|
+
assert(/always start background tasks/i.test(AGENT_TOOL.description || '') && /distinct tags?/i.test(AGENT_TOOL.description || '') && /completion notification/i.test(AGENT_TOOL.description || ''), 'agent tool description must expose async parallel tags');
|
|
41
41
|
|
|
42
42
|
mkdirSync(dataDir, { recursive: true });
|
|
43
43
|
await initProviders({ 'openai-oauth': { enabled: true } });
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Deferred agent/tool *completion* notifications must:
|
|
2
|
+
// - SURVIVE a LIVE drain (they are the intended model-visible payload, e.g.
|
|
3
|
+
// the idle-resume kick delivering an async task body this process), and
|
|
4
|
+
// - be DROPPED on a RESUME drain (only the persisted copy remains after a
|
|
5
|
+
// restart, where replaying it would inject the body out-of-order).
|
|
6
|
+
// Genuine user/steering messages survive with order preserved in both paths.
|
|
5
7
|
import test from 'node:test';
|
|
6
8
|
import assert from 'node:assert/strict';
|
|
7
9
|
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
@@ -18,47 +20,64 @@ const {
|
|
|
18
20
|
drainPendingMessages,
|
|
19
21
|
COMPLETION_NOTIFICATION_KIND,
|
|
20
22
|
markCompletionEntry,
|
|
23
|
+
_dropPendingMessageState,
|
|
21
24
|
} = await import('../src/runtime/agent/orchestrator/session/manager/pending-messages.mjs');
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
test('live drain delivers the completion notification and keeps user order', () => {
|
|
27
|
+
const sid = 'sess_live_1';
|
|
28
|
+
enqueuePendingMessage(sid, 'user first');
|
|
29
|
+
enqueuePendingMessage(sid, markCompletionEntry('Async agent task xyz finished.\n\nResult:\n> done'));
|
|
30
|
+
enqueuePendingMessage(sid, 'user second');
|
|
31
|
+
|
|
32
|
+
// In-memory queue is populated (live, same process) → completion survives.
|
|
33
|
+
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
34
|
+
assert.ok(drained.includes('user first') && drained.includes('user second'), 'user messages kept');
|
|
35
|
+
assert.ok(drained.some((t) => /finished/.test(t)), 'live completion delivered, not dropped');
|
|
36
|
+
assert.ok(drained.indexOf('user first') < drained.indexOf('user second'), 'user order preserved');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('live drain preserves interleaved [user, completion, user] order', () => {
|
|
40
|
+
// Buffered-persist copies of the live sends carry the same user texts but the
|
|
41
|
+
// completion is filtered out of the persisted path. The in-memory queue is
|
|
42
|
+
// authoritative, so the completion must stay BETWEEN the users and never be
|
|
43
|
+
// flattened to the tail as [user, user, completion].
|
|
44
|
+
const sid = 'sess_live_order';
|
|
45
|
+
enqueuePendingMessage(sid, 'user first');
|
|
46
|
+
enqueuePendingMessage(sid, markCompletionEntry('Async agent task zzz finished.\n\nResult:\n> done'));
|
|
47
|
+
enqueuePendingMessage(sid, 'user second');
|
|
48
|
+
|
|
49
|
+
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
50
|
+
const first = drained.indexOf('user first');
|
|
51
|
+
const second = drained.indexOf('user second');
|
|
52
|
+
const completion = drained.findIndex((t) => /finished/.test(t));
|
|
53
|
+
assert.ok(completion !== -1, 'completion present in live drain');
|
|
54
|
+
assert.ok(first < completion && completion < second, 'completion stays between the two users');
|
|
55
|
+
});
|
|
26
56
|
|
|
27
|
-
test('drain drops completion
|
|
28
|
-
const sid = '
|
|
57
|
+
test('resume drain drops the completion notification, keeps user messages in order', async () => {
|
|
58
|
+
const sid = 'sess_resume_1';
|
|
29
59
|
enqueuePendingMessage(sid, 'user first');
|
|
30
|
-
enqueuePendingMessage(sid,
|
|
60
|
+
enqueuePendingMessage(sid, markCompletionEntry('Async agent task xyz finished.\n\nResult:\n> done'));
|
|
31
61
|
enqueuePendingMessage(sid, 'user second');
|
|
32
62
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
63
|
+
// Flush the buffered persist to disk, then drop the in-memory queue → mimic a
|
|
64
|
+
// restart where only the persisted copy survives (the resume path).
|
|
35
65
|
await new Promise((r) => setImmediate(r));
|
|
36
66
|
await new Promise((r) => setTimeout(r, 30));
|
|
37
67
|
const store = JSON.parse(readFileSync(join(dataDir, 'session-pending-messages.json'), 'utf8'));
|
|
38
68
|
const persistedQueue = store.sessions[sid] || [];
|
|
39
69
|
const marked = persistedQueue.filter((e) => e && typeof e === 'object' && e.notificationKind === COMPLETION_NOTIFICATION_KIND);
|
|
40
70
|
assert.equal(marked.length, 1, 'completion notification persisted as a marked object');
|
|
41
|
-
|
|
71
|
+
_dropPendingMessageState(sid, { clearPersisted: false });
|
|
42
72
|
|
|
43
73
|
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
44
|
-
assert.deepEqual(drained, ['user first', 'user second'], 'completion dropped; user
|
|
45
|
-
assert.ok(!drained.some((t) => /finished/.test(t)), 'no completion prose replayed');
|
|
74
|
+
assert.deepEqual(drained, ['user first', 'user second'], 'completion dropped on resume; user order kept');
|
|
75
|
+
assert.ok(!drained.some((t) => /finished/.test(t)), 'no completion prose replayed on resume');
|
|
46
76
|
|
|
47
|
-
// A second drain
|
|
48
|
-
// not deferred.
|
|
77
|
+
// A second drain yields nothing — the completion was discarded, not deferred.
|
|
49
78
|
assert.deepEqual(drainPendingMessages(sid), []);
|
|
50
79
|
});
|
|
51
80
|
|
|
52
81
|
test.after(() => {
|
|
53
82
|
try { rmSync(dataDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
54
83
|
});
|
|
55
|
-
|
|
56
|
-
// Proves the shared tagger (used by notify.mjs, tool-exec.mjs, runtime-core.mjs)
|
|
57
|
-
// yields entries drain drops, while a real user message survives in order.
|
|
58
|
-
test('markCompletionEntry path is dropped on drain; user message survives', () => {
|
|
59
|
-
const sid = 'sess_drop_test_2';
|
|
60
|
-
enqueuePendingMessage(sid, 'real user question');
|
|
61
|
-
enqueuePendingMessage(sid, markCompletionEntry('Async agent task abc finished.\n\nResult:\n> ok'));
|
|
62
|
-
const drained = drainPendingMessages(sid).map((m) => (typeof m === 'string' ? m : m.text));
|
|
63
|
-
assert.deepEqual(drained, ['real user question']);
|
|
64
|
-
});
|