mixdog 0.9.55 → 0.9.59

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regression harness for the ctrl+wheel mouse-mode restore state machine.
4
+ *
5
+ * Run: node scripts/mouse-tracking-restore-smoke.mjs
6
+ */
7
+ import {
8
+ cancelPendingMouseTrackingRestores,
9
+ createMouseTrackingRestoreScheduler,
10
+ } from '../src/tui/app/use-mouse-input.mjs';
11
+
12
+ const expected = '\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1007l';
13
+ const expectedPassthrough = '\x1b[?1006l\x1b[?1002l\x1b[?1000l\x1b[?1007l';
14
+ let failures = 0;
15
+
16
+ function check(name, condition) {
17
+ if (condition) console.log(`ok - ${name}`);
18
+ else {
19
+ failures += 1;
20
+ console.log(`FAIL - ${name}`);
21
+ }
22
+ }
23
+
24
+ function fakeTimers() {
25
+ const entries = [];
26
+ return {
27
+ entries,
28
+ setTimeoutFn(fn, delay) {
29
+ const entry = { fn, delay, cleared: false, ran: false, unref() {} };
30
+ entries.push(entry);
31
+ return entry;
32
+ },
33
+ clearTimeoutFn(entry) {
34
+ entry.cleared = true;
35
+ },
36
+ runNext() {
37
+ const entry = entries.find((item) => !item.cleared && !item.ran);
38
+ if (!entry) return false;
39
+ entry.ran = true;
40
+ entry.fn();
41
+ return true;
42
+ },
43
+ };
44
+ }
45
+
46
+ function createHarness({ alwaysFail = false } = {}) {
47
+ const timers = fakeTimers();
48
+ const writes = [];
49
+ const stdout = {
50
+ write(value, callback) {
51
+ writes.push({ value, callback });
52
+ if (alwaysFail) callback(Object.assign(new Error('temporarily unavailable'), { code: 'EAGAIN' }));
53
+ return false; // backpressure alone is not failure
54
+ },
55
+ };
56
+ const scheduler = createMouseTrackingRestoreScheduler(stdout, timers);
57
+ scheduler.attach();
58
+ return { scheduler, timers, writes };
59
+ }
60
+
61
+ {
62
+ const { scheduler, timers, writes } = createHarness();
63
+ scheduler.schedule();
64
+ check('initial restore is delayed 700ms', timers.entries[0]?.delay === 700);
65
+ timers.runNext();
66
+ check('restore emits all mouse modes plus alternate-scroll off', writes[0]?.value === expected);
67
+ writes[0].callback(Object.assign(new Error('temporarily unavailable'), { code: 'EAGAIN' }));
68
+ check('async EAGAIN schedules a 200ms retry', timers.entries[1]?.delay === 200);
69
+ scheduler.detach();
70
+ }
71
+
72
+ {
73
+ const { scheduler, timers, writes } = createHarness({ alwaysFail: true });
74
+ scheduler.schedule();
75
+ while (timers.runNext()) { /* drain deterministic retries */ }
76
+ check('async failure is capped at five retries', writes.length === 6);
77
+ check('retry cap leaves no runnable timer', timers.runNext() === false);
78
+ scheduler.detach();
79
+ }
80
+
81
+ {
82
+ const { scheduler, timers, writes } = createHarness();
83
+ scheduler.schedule();
84
+ const staleTimer = timers.entries[0];
85
+ scheduler.schedule();
86
+ staleTimer.fn(); // simulate a callback already queued when clearTimeout ran
87
+ check('stale-generation restore callback is a no-op', writes.length === 0);
88
+ timers.runNext();
89
+ check('current generation still restores', writes.length === 1);
90
+ scheduler.detach();
91
+ }
92
+
93
+ {
94
+ const { scheduler, timers, writes } = createHarness();
95
+ scheduler.passthrough();
96
+ check('ctrl+wheel emits the guarded passthrough modes', writes[0]?.value === expectedPassthrough);
97
+ const queuedBeforeTerminalRestore = timers.entries[0];
98
+ cancelPendingMouseTrackingRestores();
99
+ queuedBeforeTerminalRestore.fn();
100
+ const writesAtRestore = writes.length;
101
+ scheduler.passthrough(); // late buffered ctrl+wheel after restoreTerminal()
102
+ check('late ctrl+wheel after terminal restore emits no mode bytes',
103
+ writes.length === writesAtRestore && timers.runNext() === false);
104
+ }
105
+
106
+ console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
107
+ process.exit(failures === 0 ? 0 : 1);
@@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process';
4
4
  import {
5
5
  PROVIDER_ACCOUNT_CONCURRENCY,
6
6
  PROVIDER_ACCOUNT_MAX_QUEUE,
7
+ PROVIDER_COOLDOWN_FAIL_FAST_MS,
7
8
  ProviderAdmissionScheduler,
8
9
  notifyCurrentAnthropicRateLimit,
9
10
  providerAdmissionKey,
@@ -579,3 +580,102 @@ test('first-byte deadlines remain 60 seconds', () => {
579
580
  assert.equal(PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS, 60_000);
580
581
  assert.equal(WS_PRE_RESPONSE_CREATED_MS, 60_000);
581
582
  });
583
+
584
+ test('long quota cooldown fails fast with a visible error and reset unblocks the lane', async () => {
585
+ assert.equal(PROVIDER_COOLDOWN_FAIL_FAST_MS, 60_000);
586
+ let now = 50_000;
587
+ const timers = new Set();
588
+ const scheduler = new ProviderAdmissionScheduler({
589
+ concurrency: 2,
590
+ now: () => now,
591
+ setTimer(fn, delay) {
592
+ const timer = { fn, at: now + delay };
593
+ timers.add(timer);
594
+ return timer;
595
+ },
596
+ clearTimer(timer) { timers.delete(timer); },
597
+ });
598
+
599
+ const gate = deferred();
600
+ const trigger = deferred();
601
+ let queuedRan = false;
602
+ const running = scheduler.run('anthropic-oauth:acct', async () => {
603
+ await gate.promise;
604
+ return 'running-done';
605
+ });
606
+ // Second slot reports the quota-window 429 mid-flight once `trigger` fires,
607
+ // while the third request is already parked in the queue.
608
+ const second = scheduler.run('anthropic-oauth:acct', async () => {
609
+ await trigger.promise;
610
+ notifyCurrentAnthropicRateLimit(
611
+ Object.assign(new Error('quota window'), { httpStatus: 429, retryAfterMs: 3_600_000 }),
612
+ );
613
+ await gate.promise;
614
+ return 'second-done';
615
+ });
616
+ const queued = scheduler.run('anthropic-oauth:acct', async () => { queuedRan = true; });
617
+ await new Promise((r) => setImmediate(r));
618
+
619
+ // Quota-window 429 (hours-long Retry-After) lands mid-flight: the queued
620
+ // request must be rejected visibly, never parked until the window resets.
621
+ trigger.resolve();
622
+ await assert.rejects(queued, (error) => error?.code === 'EPROVIDERCOOLDOWN'
623
+ && /cooldown/i.test(error?.message)
624
+ && /retryAfter=(?:1h|59m|60m)/.test(error?.message));
625
+ assert.equal(queuedRan, false);
626
+
627
+ // New submissions fail fast while the long cooldown is active.
628
+ await assert.rejects(
629
+ scheduler.run('anthropic-oauth:acct', async () => 'blocked'),
630
+ (error) => error?.code === 'EPROVIDERCOOLDOWN' && error?.httpStatus === 429,
631
+ );
632
+
633
+ // Re-login / account switch resets the cooldown and restores the lane.
634
+ assert.equal(scheduler.resetCooldowns('anthropic-oauth'), 1);
635
+ const lane = scheduler.lanes.get('anthropic-oauth:acct');
636
+ assert.equal(lane.cooldownUntil, 0);
637
+ assert.equal(lane.limit, 2);
638
+
639
+ gate.resolve();
640
+ assert.equal(await running, 'running-done');
641
+ assert.equal(await second, 'second-done');
642
+ assert.equal(await scheduler.run('anthropic-oauth:acct', async () => 'unblocked'), 'unblocked');
643
+ });
644
+
645
+ test('short burst cooldown still parks the queue without failing turns', async () => {
646
+ let now = 10_000;
647
+ const timers = new Set();
648
+ const scheduler = new ProviderAdmissionScheduler({
649
+ concurrency: 1,
650
+ now: () => now,
651
+ setTimer(fn, delay) {
652
+ const timer = { fn, at: now + delay };
653
+ timers.add(timer);
654
+ return timer;
655
+ },
656
+ clearTimer(timer) { timers.delete(timer); },
657
+ });
658
+ const advance = async (ms) => {
659
+ now += ms;
660
+ for (const timer of [...timers]) {
661
+ if (timer.at <= now) {
662
+ timers.delete(timer);
663
+ timer.fn();
664
+ }
665
+ }
666
+ await new Promise((resolve) => setImmediate(resolve));
667
+ };
668
+
669
+ await scheduler.run('anthropic-oauth:acct', async () => {
670
+ notifyCurrentAnthropicRateLimit(
671
+ Object.assign(new Error('burst'), { httpStatus: 429, retryAfterMs: 5_000 }),
672
+ );
673
+ return 'reporter';
674
+ }).catch(() => {});
675
+ let ran = false;
676
+ const parked = scheduler.run('anthropic-oauth:acct', async () => { ran = true; return 'parked-ok'; });
677
+ await new Promise((r) => setImmediate(r));
678
+ assert.equal(ran, false);
679
+ await advance(5_000);
680
+ assert.equal(await parked, 'parked-ok');
681
+ });
@@ -32,6 +32,8 @@ import {
32
32
  billableInputTokensForProvider,
33
33
  isInclusiveProvider,
34
34
  } from '../src/runtime/shared/llm/cost.mjs';
35
+ import { createProviderAuthApi } from '../src/session-runtime/provider-auth-api.mjs';
36
+ import { createProviderUsage } from '../src/session-runtime/provider-usage.mjs';
35
37
 
36
38
  function stream(events) {
37
39
  return {
@@ -41,6 +43,53 @@ function stream(events) {
41
43
  };
42
44
  }
43
45
 
46
+ test('provider setup refresh waits for keychain readiness and bypasses stale setup state', async () => {
47
+ const calls = [];
48
+ const api = createProviderAuthApi({
49
+ awaitKeychainPrewarm: async () => { calls.push('prewarm'); },
50
+ reloadFullConfig: () => { calls.push('reload'); },
51
+ cachedProviderSetup: async (options) => {
52
+ calls.push(['setup', options]);
53
+ return { generation: 2 };
54
+ },
55
+ });
56
+
57
+ assert.deepEqual(await api.getProviderSetup({ refresh: true }), { generation: 2 });
58
+ assert.deepEqual(calls, ['prewarm', 'reload', ['setup', { force: true }]]);
59
+ });
60
+
61
+ test('forced provider setup waits for an in-flight snapshot and then rebuilds it', async () => {
62
+ let releaseFirst;
63
+ let builds = 0;
64
+ const usage = createProviderUsage({
65
+ caches: {
66
+ providerSetupCache: {},
67
+ providerSetupQuickCache: {},
68
+ providerSetupPromise: null,
69
+ },
70
+ displayConfig: () => ({}),
71
+ providerSetup: async () => {
72
+ builds += 1;
73
+ if (builds === 1) await new Promise((resolve) => { releaseFirst = resolve; });
74
+ return { generation: builds };
75
+ },
76
+ getReg: () => new Map(),
77
+ getConfig: () => ({}),
78
+ getProviderSetupWarmupTimer: () => null,
79
+ scheduleProviderSetupWarmup() {},
80
+ isCloseRequested: () => false,
81
+ });
82
+
83
+ const initial = usage.cachedProviderSetup();
84
+ await Promise.resolve();
85
+ const refreshed = usage.cachedProviderSetup({ force: true });
86
+ releaseFirst();
87
+
88
+ assert.deepEqual(await initial, { generation: 1 });
89
+ assert.deepEqual(await refreshed, { generation: 2 });
90
+ assert.equal(builds, 2);
91
+ });
92
+
44
93
  test('current vendor preset defaults and OpenCode Go protocol routes are pinned', () => {
45
94
  assert.equal(OPENAI_COMPAT_PRESETS.xai.defaultModel, 'grok-4.5');
46
95
  assert.equal(OPENAI_COMPAT_PRESETS.deepseek.defaultModel, 'deepseek-v4-pro');
@@ -464,8 +464,11 @@ test('abort during adoption detaches once, resolves once, and releases once', as
464
464
  configurable: true,
465
465
  get() {
466
466
  abortedReads += 1;
467
- if (abortedReads === 2) controller.abort(new Error('cancelled during adoption'));
468
- return abortedReads >= 2;
467
+ // Read #1: the bounded admission-wait wrapper (_acquireShellLeaseBounded).
468
+ // Read #2: the pre-spawn abort check. Read #3: the _autoBackground
469
+ // adoption re-check — the abort must land exactly there.
470
+ if (abortedReads === 3) controller.abort(new Error('cancelled during adoption'));
471
+ return abortedReads >= 3;
469
472
  },
470
473
  });
471
474
  let detachCalls = 0;
@@ -238,4 +238,42 @@ function refsFor(messages, opts) {
238
238
  assert(rdb.rows.length === rowsBefore, 'RESTART+drop: no duplicate rows created')
239
239
  }
240
240
 
241
+ // ── WARM runtime + JSON-cloned replay (desktop/session resume) ───────────────
242
+ // The memory daemon can outlive a surface that reloads the same session JSON.
243
+ // Every transcript message is then a new object while the ingest runtime's
244
+ // WeakMaps remain warm. The replay must reuse prior source_refs, and only a
245
+ // genuinely appended suffix may insert.
246
+ {
247
+ const rdb = createFakeDb()
248
+ const S = 'warm-json-clone-sess'
249
+ const rt = makeRuntime(rdb)
250
+ const original = [
251
+ mk('user', 'optimize session switching'),
252
+ mk('assistant', 'I will inspect the transition path'),
253
+ mk('user', 'go ahead'),
254
+ mk('assistant', 'working'),
255
+ ]
256
+ await rt.ingestSessionMessages({ sessionId: S, messages: original, limit: 5000 })
257
+ const beforeReplay = rdb.rows.length
258
+
259
+ const clonedReplay = JSON.parse(JSON.stringify(original))
260
+ await rt.ingestSessionMessages({ sessionId: S, messages: clonedReplay, limit: 5000 })
261
+ assert(
262
+ rdb.rows.length === beforeReplay,
263
+ `WARM CLONE: JSON-reloaded transcript must insert 0 duplicates (before=${beforeReplay} after=${rdb.rows.length})`,
264
+ )
265
+
266
+ // Reload the whole array again and append text identical to an older row.
267
+ // Snapshot reconciliation reuses the cloned prefix while the suffix consumes
268
+ // the historical occurrence high-water.
269
+ const clonedWithAppend = JSON.parse(JSON.stringify(original))
270
+ clonedWithAppend.push(mk('assistant', 'working'))
271
+ await rt.ingestSessionMessages({ sessionId: S, messages: clonedWithAppend, limit: 5000 })
272
+ assert(rdb.rows.length === beforeReplay + 1, 'WARM CLONE: exactly one genuinely appended row must insert')
273
+
274
+ const clonedAgain = JSON.parse(JSON.stringify(clonedWithAppend))
275
+ await rt.ingestSessionMessages({ sessionId: S, messages: clonedAgain, limit: 5000 })
276
+ assert(rdb.rows.length === beforeReplay + 1, 'WARM CLONE: replay after append must remain idempotent')
277
+ }
278
+
241
279
  process.stdout.write('session ingest compaction-append smoke passed \u2713\n')
@@ -122,6 +122,21 @@ test('post-turn drain does not send queued slash command to model', async () =>
122
122
  assert.equal(bag.pending[0].content, '/clear');
123
123
  });
124
124
 
125
+ test('restoreQueued can edit one visible steering entry without draining its siblings', () => {
126
+ const { flow, bag } = makeFlow();
127
+ const first = flow.makeQueueEntry('first follow-up', { mode: 'prompt' });
128
+ const second = flow.makeQueueEntry('second follow-up', { mode: 'prompt' });
129
+ bag.pending.push(first, second);
130
+ bag.getState().queued = [first, second];
131
+
132
+ const restored = flow.restoreQueued('current draft', second.id);
133
+
134
+ assert.equal(restored.count, 1);
135
+ assert.equal(restored.text, 'second follow-up\ncurrent draft');
136
+ assert.deepEqual(bag.pending.map((entry) => entry.id), [first.id]);
137
+ assert.deepEqual(bag.getState().queued.map((entry) => entry.id), [first.id]);
138
+ });
139
+
125
140
  // Minimal store bag for createRunTurn: only the surface the streaming/steering
126
141
  // finalize path touches. runtime.ask is a caller-supplied mock that drives the
127
142
  // text-delta / steer-message callbacks.
@@ -10,6 +10,10 @@ import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
10
10
  function makeEngine({
11
11
  autoClearBeforeSubmit,
12
12
  autoClearEnabled = false,
13
+ autoClearConfig = null,
14
+ sessionLastUsedAt = Date.now() - 1000,
15
+ contextStatus = null,
16
+ compactionSettings = {},
13
17
  runtimeClear = async () => true,
14
18
  } = {}) {
15
19
  let seq = 0;
@@ -29,9 +33,10 @@ function makeEngine({
29
33
  session: {
30
34
  id: 'session_1',
31
35
  messages: [{ role: 'user', content: 'existing prompt' }],
32
- lastUsedAt: Date.now() - 1000,
36
+ lastUsedAt: sessionLastUsedAt,
33
37
  },
34
- getCompactionSettings: () => ({}),
38
+ getCompactionSettings: () => compactionSettings,
39
+ contextStatus: () => contextStatus,
35
40
  clear: runtimeClear,
36
41
  consumePendingSessionReset: () => null,
37
42
  ask: async (text) => {
@@ -47,6 +52,7 @@ function makeEngine({
47
52
  listeners: new Set(),
48
53
  pendingNotificationKeys: new Set(),
49
54
  displayedExecutionNotificationKeys: new Set(),
55
+ clearToastTimers: () => {},
50
56
  getState: () => state,
51
57
  set: (patch) => {
52
58
  if (!patch || typeof patch !== 'object') return false;
@@ -65,7 +71,7 @@ function makeEngine({
65
71
  executed.push(text);
66
72
  state = { ...state, items: [...state.items, { kind: 'user', id, text }] };
67
73
  },
68
- autoClearState: () => ({ enabled: autoClearEnabled, idleMs: 0, minContextPercent: 0 }),
74
+ autoClearState: () => autoClearConfig || ({ enabled: autoClearEnabled, idleMs: 0, minContextPercent: 0 }),
69
75
  agentStatusState: () => ({}),
70
76
  routeState: () => ({}),
71
77
  syncContextStats: () => {},
@@ -164,6 +170,51 @@ test('runtime clear false skips auto-clear and sends the first post-failure prom
164
170
  assert.equal(bag.runtime.session.messages[0].content, 'existing prompt', 'failed clear preserves the runtime session');
165
171
  });
166
172
 
173
+ test('idle submit compacts when a zero usedTokens field has a live estimate above the context gate', async () => {
174
+ const clearCalls = [];
175
+ const { api, getState } = makeEngine({
176
+ autoClearConfig: { enabled: true, idleMs: 60_000, minContextPercent: 10 },
177
+ sessionLastUsedAt: Date.now() - 120_000,
178
+ contextStatus: {
179
+ usedTokens: 0,
180
+ currentEstimatedTokens: 20,
181
+ compaction: { triggerTokens: 100 },
182
+ },
183
+ compactionSettings: { compactType: 'summary' },
184
+ runtimeClear: async (options) => {
185
+ clearCalls.push(options);
186
+ return true;
187
+ },
188
+ });
189
+
190
+ assert.equal(api.submit('after real idle'), true);
191
+ await tick(); await tick(); await tick();
192
+ assert.deepEqual(clearCalls, [{ compactType: 'summary', requireCompactSuccess: true }]);
193
+ assert.equal(getState().items.some((item) => item.label === 'Auto-clear complete'), true);
194
+ });
195
+
196
+ test('idle submit skips auto-clear when context gate operands are non-finite', async () => {
197
+ for (const contextStatus of [
198
+ { usedTokens: Infinity, compaction: { triggerTokens: 100 } },
199
+ { usedTokens: 20, compaction: { triggerTokens: Infinity } },
200
+ ]) {
201
+ const clearCalls = [];
202
+ const { api } = makeEngine({
203
+ autoClearConfig: { enabled: true, idleMs: 60_000, minContextPercent: 10 },
204
+ sessionLastUsedAt: Date.now() - 120_000,
205
+ contextStatus,
206
+ compactionSettings: { compactType: 'summary' },
207
+ runtimeClear: async (options) => {
208
+ clearCalls.push(options);
209
+ return true;
210
+ },
211
+ });
212
+ assert.equal(api.submit('after real idle'), true);
213
+ await tick(); await tick();
214
+ assert.deepEqual(clearCalls, []);
215
+ }
216
+ });
217
+
167
218
  test('late runtime clear false keeps the UI skipped and sends the first post-timeout prompt', async () => {
168
219
  let resolveClear;
169
220
  let harness;
@@ -185,9 +185,8 @@ function assertOk(name, result, pattern = null) {
185
185
  {
186
186
  const publicStrategy = resolveCacheStrategy('worker');
187
187
  assert(publicStrategy.tools === 'none', `Anthropic tools must not spend a cache_control BP: ${JSON.stringify(publicStrategy)}`);
188
- // BP1~3 stay 1h (pool-stable prefix); volatile message tail is 5m for all
189
- // sessions see resolveCacheStrategy docs (trace p99 gap 4.5min).
190
- assert(publicStrategy.system === '1h' && publicStrategy.tier3 === '1h' && publicStrategy.messages === '5m', `public cache tiers changed unexpectedly: ${JSON.stringify(publicStrategy)}`);
188
+ // BP1~3 and the volatile message tail stay 1h; see resolveCacheStrategy.
189
+ assert(publicStrategy.system === '1h' && publicStrategy.tier3 === '1h' && publicStrategy.messages === '1h', `public cache tiers changed unexpectedly: ${JSON.stringify(publicStrategy)}`);
191
190
  assert(cacheCapabilityForProvider('anthropic-oauth') === 'explicit-breakpoint', 'Anthropic OAuth should remain explicit-breakpoint');
192
191
  assert(cacheCapabilityForProvider('openai-oauth') === 'key-prefix', 'OpenAI OAuth should remain key-prefix');
193
192
  assert(cacheCapabilityForProvider('xai') === 'key-prefix', 'xAI should remain key-prefix');
@@ -0,0 +1,113 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import sliceAnsi from 'slice-ansi';
4
+ import stringWidth from 'string-width';
5
+
6
+ // Make the Windows Terminal policy deterministic on every test host. Dynamic
7
+ // imports matter because both policy copies resolve the gate once at load.
8
+ process.env.MIXDOG_TUI_AMBIGUOUS_WIDE = '1';
9
+
10
+ const [{ displayStringWidth }, {
11
+ default: wrapText,
12
+ sliceTextByDisplayWidth,
13
+ sliceTextByDisplayWidthWithPolicy,
14
+ }, { default: Output }] = await Promise.all([
15
+ import('../vendor/ink/build/display-width.js'),
16
+ import('../vendor/ink/build/wrap-text.js'),
17
+ import('../vendor/ink/build/output.js'),
18
+ ]);
19
+
20
+ const BODY = '①첫째 ②둘째 ③셋째 한국어 본문 텍스트가 이어집니다';
21
+
22
+ test('Windows Terminal wide glyph wrapping keeps every transcript body line in budget', () => {
23
+ const budget = 12;
24
+ const lines = wrapText(BODY, budget, 'wrap').split('\n');
25
+ assert.ok(lines.length > 1, 'harness input must wrap');
26
+ for (const line of lines) {
27
+ assert.ok(
28
+ displayStringWidth(line) <= budget,
29
+ `${JSON.stringify(line)} is ${displayStringWidth(line)} cells (budget ${budget})`,
30
+ );
31
+ }
32
+ });
33
+
34
+ test('Output materializes the reserved cell after a policy-widened glyph', () => {
35
+ const text = '②{ M }';
36
+ assert.equal(stringWidth(text), 6, 'plain/native cursor advance is the broken 1-cell baseline');
37
+ assert.equal(displayStringWidth(text), 7, 'Ink layout reserves two cells for ②');
38
+
39
+ const output = new Output({ width: 20, height: 1 });
40
+ output.write(0, 0, text, { transformers: [] });
41
+ const rendered = output.get().output;
42
+
43
+ assert.equal(rendered, '② { M }', 'serialized bytes must advance over the reserved cell');
44
+ assert.equal(stringWidth(rendered), 7, 'emitted terminal cursor width matches the 7-cell layout');
45
+
46
+ const excluded = new Output({ width: 20, height: 1 });
47
+ excluded.write(0, 0, '└A ←B ↻C', { transformers: [] });
48
+ assert.equal(excluded.get().output, '└A ←B ↻C', 'box/figure glyphs must not gain padding');
49
+ });
50
+
51
+ test('horizontal output clipping uses the wide-glyph display columns', () => {
52
+ const budget = 12;
53
+ const output = new Output({ width: 40, height: 1 });
54
+ output.clip({ x1: 0, x2: budget });
55
+ output.write(0, 0, BODY, { transformers: [] });
56
+ output.unclip();
57
+
58
+ const rendered = output.get().output;
59
+ assert.ok(
60
+ stringWidth(rendered) <= budget,
61
+ `${JSON.stringify(rendered)} escaped clip at ${stringWidth(rendered)} emitted cells`,
62
+ );
63
+ });
64
+
65
+ test('left-edge clipping drops a glyph intersected by x1 and keeps mixed Korean text aligned', () => {
66
+ const output = new Output({ width: 40, height: 1 });
67
+ output.clip({ x1: 3, x2: 15 });
68
+ output.write(0, 0, '가①나②다③ 한국어', { transformers: [] });
69
+ output.unclip();
70
+
71
+ const rendered = output.get().output;
72
+ assert.ok(rendered.startsWith(' 나'), JSON.stringify(rendered));
73
+ assert.ok(stringWidth(rendered) <= 15);
74
+ });
75
+
76
+ test('one-cell clip drops a two-cell circled digit instead of crossing x2', () => {
77
+ const output = new Output({ width: 10, height: 1 });
78
+ output.clip({ x1: 0, x2: 1 });
79
+ output.write(0, 0, '①A', { transformers: [] });
80
+ output.unclip();
81
+
82
+ const rendered = output.get().output;
83
+ assert.equal(rendered, '');
84
+ assert.ok(displayStringWidth(rendered) <= 1);
85
+ });
86
+
87
+ test('policy-off and no-problem slices preserve upstream slice-ansi bytes', () => {
88
+ const styledProblem = '\x1b[31m①AB\x1b[39m';
89
+ assert.equal(
90
+ sliceTextByDisplayWidthWithPolicy(styledProblem, 0, 2, false),
91
+ sliceAnsi(styledProblem, 0, 2),
92
+ );
93
+
94
+ const styledPlain = '\x1b[31mplain\x1b[39m';
95
+ assert.equal(
96
+ sliceTextByDisplayWidth(styledPlain, 1, 4),
97
+ sliceAnsi(styledPlain, 1, 4),
98
+ );
99
+
100
+ const excludedArrows = '\x1b[36m←↑→↓ ↻ tail\x1b[39m';
101
+ assert.equal(
102
+ sliceTextByDisplayWidth(excludedArrows, 1, 7),
103
+ sliceAnsi(excludedArrows, 1, 7),
104
+ );
105
+ });
106
+
107
+ test('left clipping re-opens parameterized OSC 8 hyperlinks', () => {
108
+ const open = '\x1b]8;id=x;https://example.test\x07';
109
+ const linked = `${open}가①나\x1b]8;;\x07`;
110
+ const sliced = sliceTextByDisplayWidth(linked, 2, 6);
111
+ assert.ok(sliced.startsWith(open), JSON.stringify(sliced));
112
+ assert.match(sliced, /①나/);
113
+ });
@@ -9,6 +9,11 @@ import { rm } from 'node:fs/promises';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { fileURLToPath, pathToFileURL } from 'node:url';
11
11
 
12
+ // Match the production CLI (src/cli.mjs defaults NODE_ENV to production):
13
+ // otherwise the bench measures react-reconciler's dev-build overhead that
14
+ // real runs no longer pay.
15
+ process.env.NODE_ENV ||= 'production';
16
+
12
17
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
13
18
  const entry = join(ROOT, 'scripts', 'tui-runtime-load-bench-entry.jsx');
14
19
  const outfile = join(ROOT, 'scripts', '.tui-runtime-load-bench.tmp.mjs');