mixdog 0.9.34 → 0.9.36

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.
Files changed (71) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -2
  3. package/scripts/_devtools-stub.mjs +1 -0
  4. package/scripts/_jitter-fuzz.jsx +42 -0
  5. package/scripts/_jitter-fuzz.mjs +44410 -0
  6. package/scripts/_jitter-fuzz2.jsx +30 -0
  7. package/scripts/_jitter-fuzz2.mjs +44400 -0
  8. package/scripts/_jitter-probe.jsx +35 -0
  9. package/scripts/_jitter-probe.mjs +44397 -0
  10. package/scripts/_jp2.jsx +16 -0
  11. package/scripts/_jp2.mjs +45614 -0
  12. package/scripts/agent-live-arg-guard-smoke.mjs +20 -0
  13. package/scripts/agent-live-path-suffix-smoke.mjs +34 -0
  14. package/scripts/agent-live-toolcall-args-smoke.mjs +45 -0
  15. package/scripts/apply-patch-edit-smoke.mjs +71 -0
  16. package/scripts/async-notify-settlement-test.mjs +99 -0
  17. package/scripts/forwarder-rebind-tail-test.mjs +67 -0
  18. package/scripts/live-worker-smoke.mjs +1 -1
  19. package/scripts/pending-completion-drop-test.mjs +46 -27
  20. package/scripts/provider-toolcall-test.mjs +1 -0
  21. package/scripts/recall-bench-cases.json +1 -0
  22. package/scripts/session-bench.mjs +44 -0
  23. package/scripts/shell-hardening-test.mjs +209 -0
  24. package/scripts/ship-mode-test.mjs +98 -0
  25. package/scripts/steering-drain-buckets-test.mjs +59 -0
  26. package/scripts/tool-smoke.mjs +46 -10
  27. package/scripts/worker-notify-rejection-test.mjs +51 -0
  28. package/src/lib/mixdog-debug.cjs +69 -0
  29. package/src/rules/agent/00-common.md +4 -5
  30. package/src/rules/agent/00-core.md +13 -20
  31. package/src/rules/agent/20-skip-protocol.md +3 -8
  32. package/src/rules/agent/30-explorer.md +31 -54
  33. package/src/rules/lead/01-general.md +3 -6
  34. package/src/rules/lead/lead-brief.md +10 -16
  35. package/src/rules/lead/lead-tool.md +3 -5
  36. package/src/rules/shared/01-tool.md +7 -9
  37. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +13 -2
  38. package/src/runtime/agent/orchestrator/mcp/client.mjs +7 -7
  39. package/src/runtime/agent/orchestrator/providers/gemini.mjs +19 -23
  40. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +21 -5
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +15 -0
  42. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -13
  43. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +21 -7
  44. package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +1 -5
  45. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +71 -1
  46. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +17 -9
  47. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +127 -4
  48. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +105 -28
  49. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  50. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +7 -3
  52. package/src/runtime/memory/lib/memory-embed.mjs +1 -1
  53. package/src/runtime/memory/lib/query-handlers.mjs +25 -10
  54. package/src/runtime/memory/tool-defs.mjs +3 -3
  55. package/src/runtime/shared/background-tasks.mjs +21 -1
  56. package/src/runtime/shared/tool-execution-contract.mjs +29 -16
  57. package/src/session-runtime/model-route-api.mjs +3 -3
  58. package/src/session-runtime/provider-auth-api.mjs +13 -0
  59. package/src/session-runtime/runtime-core.mjs +40 -6
  60. package/src/session-runtime/tool-catalog.mjs +3 -2
  61. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  62. package/src/standalone/agent-tool/notify.mjs +14 -1
  63. package/src/tui/dist/index.mjs +69 -29
  64. package/src/tui/engine/context-state.mjs +13 -4
  65. package/src/tui/engine/session-flow.mjs +26 -13
  66. package/src/tui/engine/turn.mjs +7 -8
  67. package/src/tui/engine.mjs +4 -2
  68. package/src/vendor/statusline/bin/statusline-route.mjs +60 -4
  69. package/src/vendor/statusline/src/gateway/route-meta.mjs +37 -8
  70. package/src/workflows/default/WORKFLOW.md +26 -32
  71. 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); });
@@ -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/i.test(AGENT_TOOL.description || '') && /completion notification/i.test(AGENT_TOOL.description || ''), 'agent tool description must expose async parallel 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
- // Proves deferred agent/tool *completion* notifications are DROPPED on drain
2
- // (never replayed out-of-order on a later session resume) while genuine
3
- // user/steering messages in the same queue survive with order preserved.
4
- // Owner decision: out-of-order replay is worse than loss.
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
- function completionEntry(text) {
24
- return { content: text, text, notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Date.now() };
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 notifications, keeps user messages in order', async () => {
28
- const sid = 'sess_drop_test_1';
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, completionEntry('Async agent task xyz finished.\n\nResult:\n> done'));
60
+ enqueuePendingMessage(sid, markCompletionEntry('Async agent task xyz finished.\n\nResult:\n> done'));
31
61
  enqueuePendingMessage(sid, 'user second');
32
62
 
33
- // Let the buffered persist flush to disk so we also verify the on-disk marker
34
- // (the shape a later resume would drain).
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
- assert.ok(persistedQueue.includes('user first') && persistedQueue.includes('user second'), 'user messages persisted as plain strings');
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 messages kept in order');
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 (post-resume) yields nothing — the completion was discarded,
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
- });
@@ -332,6 +332,7 @@ test('gemini cache usage: official cached token fields are subsets of prompt tok
332
332
  assert.equal(sdkAlias.inputTokens, 1200);
333
333
  assert.equal(sdkAlias.reportedCachedTokens, 500);
334
334
  assert.equal(sdkAlias.cachedTokens, 500);
335
+ assert.notEqual(sdkAlias.inputTokens, 0, 'snake_case SDK aliases must remain visible to provider return usage');
335
336
  });
336
337
 
337
338
  test('gemini cache usage: clamps over-reported cache and falls back only for attached cachedContent', () => {
@@ -1,5 +1,6 @@
1
1
  [
2
2
  { "id": "browse-last", "label": "query-less recent browse (period=last)", "args": { "period": "last", "limit": 10 }, "expect": "browse" },
3
+ { "id": "last-query-topic", "label": "topic inside recent sessions (period=last + query)", "args": { "query": "last", "period": "last", "limit": 5 }, "expect": { "kind": "results", "topNContains": ["last"], "topN": 10, "recencyOrdered": true } },
3
4
  { "id": "period-24h", "label": "period window 24h", "args": { "period": "24h", "limit": 10 }, "expect": "browse" },
4
5
  { "id": "period-7d", "label": "period window 7d", "args": { "period": "7d", "limit": 10 }, "expect": "browse" },
5
6
  { "id": "scope-all", "label": "all-scope query", "args": { "query": "recall", "projectScope": "all", "limit": 10 }, "expect": "results" },
@@ -831,6 +831,39 @@ function buildToolDiagnostics(rows, failureRows = []) {
831
831
  }
832
832
  identicalCallRepeats.sort((a, b) => b.count - a.count);
833
833
 
834
+ const editFragmentation = [];
835
+ for (const [sid, srows] of groupBy(tools.filter((r) => sessionId(r)), sessionId).entries()) {
836
+ const patches = srows
837
+ .filter((r) => String(field(r, 'tool_name') || '') === 'apply_patch')
838
+ .sort((a, b) => Number(a.ts || 0) - Number(b.ts || 0));
839
+ if (patches.length < 2) continue;
840
+ const byIter = countBy(patches, (r) => num(r, 'iteration'));
841
+ const multiPatchTurns = byIter.filter(([, c]) => c > 1).length;
842
+ let crossTurnPatch = 0;
843
+ for (let i = 1; i < patches.length; i++) {
844
+ const earlier = patches[i - 1];
845
+ const later = patches[i];
846
+ const ei = num(earlier, 'iteration');
847
+ const li = num(later, 'iteration');
848
+ if (ei == null || li == null || ei === li) continue;
849
+ const dt = Number(later.ts || 0) - Number(earlier.ts || 0);
850
+ if (li === ei + 1 || dt < 60_000) crossTurnPatch++;
851
+ }
852
+ const fragScore = multiPatchTurns + crossTurnPatch;
853
+ if (fragScore < 2) continue;
854
+ editFragmentation.push({
855
+ session_id: sid,
856
+ agent: field(patches[0], 'agent'),
857
+ patch_calls: patches.length,
858
+ multi_patch_turns: multiPatchTurns,
859
+ cross_turn_patch: crossTurnPatch,
860
+ frag_score: fragScore,
861
+ start_it: num(patches[0], 'iteration'),
862
+ end_it: num(patches[patches.length - 1], 'iteration'),
863
+ });
864
+ }
865
+ editFragmentation.sort((a, b) => b.frag_score - a.frag_score);
866
+
834
867
  return {
835
868
  total_tool_calls: tools.length,
836
869
  total_tool_ms: sum(tools.map((r) => num(r, 'tool_ms'))),
@@ -850,6 +883,7 @@ function buildToolDiagnostics(rows, failureRows = []) {
850
883
  sequential_tool_clusters: sequentialToolClusters,
851
884
  readonly_stalls: readonlyStalls.slice(0, 20),
852
885
  identical_call_repeats: identicalCallRepeats.slice(0, 20),
886
+ edit_fragmentation: editFragmentation.slice(0, 20),
853
887
  };
854
888
  }
855
889
 
@@ -1246,6 +1280,10 @@ function buildIssues(routeGroups, cache, tools) {
1246
1280
  const toolSummary = c.tools.map((x) => `${x.tool}×${x.count}`).join(', ');
1247
1281
  issues.push({ severity: 'low', type: 'tool_churn_cluster', message: `sequential single-tool cluster x${c.count} over ${fmtMs(c.span_ms)}: ${toolSummary}` });
1248
1282
  }
1283
+ for (const e of (tools.edit_fragmentation || []).slice(0, 5)) {
1284
+ if (e.frag_score < 2) continue;
1285
+ issues.push({ severity: 'low', type: 'edit_fragmentation', message: `edit fragmentation x${e.frag_score} (multi-patch turns ${e.multi_patch_turns}, cross-turn ${e.cross_turn_patch}): ${e.agent || '-'}`, session_id: e.session_id });
1286
+ }
1249
1287
  const rank = { high: 0, medium: 1, low: 2 };
1250
1288
  return issues.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9));
1251
1289
  }
@@ -1629,6 +1667,12 @@ function renderText(report) {
1629
1667
  if (c.examples?.length) lines.push(` e.g. ${c.examples.join(' | ')}`);
1630
1668
  }
1631
1669
  }
1670
+ if (report.tools.edit_fragmentation?.length) {
1671
+ lines.push('edit fragmentation:');
1672
+ for (const e of report.tools.edit_fragmentation.slice(0, 8)) {
1673
+ lines.push(`- ${e.agent || '-'} it=${e.start_it}→${e.end_it} score=${e.frag_score} multi=${e.multi_patch_turns} cross=${e.cross_turn_patch}`);
1674
+ }
1675
+ }
1632
1676
  lines.push(`missed parallelism heuristic: ${report.tools.missed_parallelism_heuristic.consecutive_single_tool_batches} close single-tool batches`);
1633
1677
  lines.push('');
1634
1678
  }