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.
- package/package.json +3 -1
- package/scripts/_smoke_wd.mjs +7 -0
- package/scripts/agent-terminal-reap-test.mjs +127 -2
- package/scripts/compact-file-reattach-test.mjs +88 -0
- package/scripts/compact-pressure-test.mjs +45 -21
- package/scripts/compact-recall-digest-test.mjs +57 -0
- package/scripts/compact-smoke.mjs +35 -39
- package/scripts/desktop-session-bridge-test.mjs +271 -9
- package/scripts/generate-oc-icons.mjs +11 -0
- package/scripts/live-share-test.mjs +148 -0
- package/scripts/live-worker-smoke.mjs +1 -1
- package/scripts/max-output-recovery-test.mjs +12 -1
- package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
- package/scripts/provider-admission-scheduler-test.mjs +100 -0
- package/scripts/provider-contract-test.mjs +49 -0
- package/scripts/resource-admission-test.mjs +5 -2
- package/scripts/session-ingest-compaction-smoke.mjs +38 -0
- package/scripts/steering-drain-buckets-test.mjs +15 -0
- package/scripts/submit-commandbusy-race-test.mjs +54 -3
- package/scripts/tool-smoke.mjs +2 -3
- package/scripts/tui-ambiguous-width-test.mjs +113 -0
- package/scripts/tui-runtime-load-bench.mjs +5 -0
- package/scripts/tui-transcript-perf-test.mjs +167 -0
- package/src/app.mjs +23 -0
- package/src/cli.mjs +6 -0
- package/src/lib/keychain-cjs.cjs +70 -20
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
- package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
- package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
- package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
- package/src/runtime/channels/lib/scheduler.mjs +8 -6
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
- package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
- package/src/runtime/channels/lib/whisper-language.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
- package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
- package/src/runtime/memory/lib/query-handlers.mjs +10 -4
- package/src/runtime/memory/lib/recall-format.mjs +43 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
- package/src/runtime/shared/err-text.mjs +4 -1
- package/src/runtime/shared/resource-admission.mjs +11 -0
- package/src/runtime/shared/schedule-model-ref.mjs +28 -0
- package/src/runtime/shared/schedule-session-run.mjs +63 -0
- package/src/runtime/shared/schedules-db.mjs +8 -3
- package/src/runtime/shared/tool-result-summary.mjs +4 -1
- package/src/runtime/shared/tool-surface.mjs +7 -7
- package/src/runtime/shared/transcript-writer.mjs +17 -0
- package/src/session-runtime/channel-config-api.mjs +11 -0
- package/src/session-runtime/config-helpers.mjs +9 -6
- package/src/session-runtime/context-status.mjs +80 -3
- package/src/session-runtime/lifecycle-api.mjs +154 -40
- package/src/session-runtime/provider-auth-api.mjs +21 -2
- package/src/session-runtime/provider-usage.mjs +4 -1
- package/src/session-runtime/resource-api.mjs +12 -11
- package/src/session-runtime/runtime-core.mjs +45 -11
- package/src/session-runtime/session-text.mjs +56 -6
- package/src/session-runtime/session-turn-api.mjs +70 -2
- package/src/session-runtime/tool-catalog.mjs +2 -1
- package/src/session-runtime/workflow-agents-api.mjs +2 -2
- package/src/standalone/agent-tool/render.mjs +5 -1
- package/src/standalone/agent-tool.mjs +63 -3
- package/src/standalone/channel-admin.mjs +19 -3
- package/src/standalone/channel-daemon.mjs +40 -0
- package/src/tui/app/resume-picker.mjs +5 -1
- package/src/tui/app/settings-picker.mjs +19 -0
- package/src/tui/app/transcript-window.mjs +45 -8
- package/src/tui/app/use-mouse-input.mjs +101 -31
- package/src/tui/app/use-transcript-window.mjs +53 -9
- package/src/tui/components/ToolExecution.jsx +3 -4
- package/src/tui/components/TranscriptItem.jsx +3 -0
- package/src/tui/dist/index.mjs +17391 -14920
- package/src/tui/engine/context-state.mjs +10 -1
- package/src/tui/engine/live-share.mjs +390 -0
- package/src/tui/engine/queue-helpers.mjs +23 -0
- package/src/tui/engine/session-api-ext.mjs +439 -40
- package/src/tui/engine/session-api.mjs +7 -1
- package/src/tui/engine/session-flow.mjs +21 -7
- package/src/tui/engine/tool-card-results.mjs +3 -1
- package/src/tui/engine/turn.mjs +57 -4
- package/src/tui/engine.mjs +211 -7
- package/src/tui/index.jsx +2 -0
- package/src/tui/lib/voice-setup.mjs +10 -4
- package/src/tui/paste-attachments.mjs +4 -1
- package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
- package/src/workflows/default/WORKFLOW.md +3 -0
- package/vendor/ink/build/display-width.js +14 -0
- package/vendor/ink/build/output.js +24 -3
- package/vendor/ink/build/wrap-text.d.ts +2 -0
- package/vendor/ink/build/wrap-text.js +45 -4
|
@@ -7,9 +7,13 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
+
SESSION_SUMMARY_INDEX_VERSION,
|
|
10
11
|
_normalizeSummaryIndex,
|
|
11
12
|
_sessionSummary,
|
|
12
13
|
} from '../src/runtime/agent/orchestrator/session/store-summary-index.mjs';
|
|
14
|
+
import {
|
|
15
|
+
listStoredSessionSummaries as listLightweightSessionSummaries,
|
|
16
|
+
} from '../src/runtime/agent/orchestrator/session/store-summary-reader.mjs';
|
|
13
17
|
import { createLifecycleApi, resolveResumeCwd } from '../src/session-runtime/lifecycle-api.mjs';
|
|
14
18
|
import { createCwdPlugins } from '../src/session-runtime/cwd-plugins.mjs';
|
|
15
19
|
import {
|
|
@@ -99,6 +103,80 @@ test('desktop classification is optional and round-trips through the existing su
|
|
|
99
103
|
assert.equal(malformed.desktopSession, null);
|
|
100
104
|
});
|
|
101
105
|
|
|
106
|
+
test('cold summary reader loads the sidecar without importing the full store', () => {
|
|
107
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-cold-summary-reader-'));
|
|
108
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
109
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
110
|
+
try {
|
|
111
|
+
writeFileSync(join(root, 'session-summaries.json'), JSON.stringify({
|
|
112
|
+
version: SESSION_SUMMARY_INDEX_VERSION,
|
|
113
|
+
rows: [{
|
|
114
|
+
id: 'cold_reader',
|
|
115
|
+
updatedAt: 10,
|
|
116
|
+
cwd: '/workspace',
|
|
117
|
+
preview: 'Cold reader session',
|
|
118
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
119
|
+
}],
|
|
120
|
+
}));
|
|
121
|
+
assert.deepEqual(listLightweightSessionSummaries().map((row) => row.id), ['cold_reader']);
|
|
122
|
+
} finally {
|
|
123
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
124
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
125
|
+
rmSync(root, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('loadSession reuses a parsed session until the atomic file identity changes', () => {
|
|
130
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-session-load-cache-'));
|
|
131
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
132
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
133
|
+
try {
|
|
134
|
+
const sessions = join(root, 'sessions');
|
|
135
|
+
mkdirSync(sessions, { recursive: true });
|
|
136
|
+
const target = join(sessions, 'cached_parse.json');
|
|
137
|
+
writeFileSync(target, JSON.stringify({
|
|
138
|
+
id: 'cached_parse',
|
|
139
|
+
messages: [{ role: 'user', content: 'first version' }],
|
|
140
|
+
}));
|
|
141
|
+
const first = loadSession('cached_parse');
|
|
142
|
+
const second = loadSession('cached_parse');
|
|
143
|
+
assert.equal(second, first);
|
|
144
|
+
writeFileSync(target, JSON.stringify({
|
|
145
|
+
id: 'cached_parse',
|
|
146
|
+
messages: [{ role: 'user', content: 'changed version with a different size' }],
|
|
147
|
+
}));
|
|
148
|
+
const changed = loadSession('cached_parse');
|
|
149
|
+
assert.notEqual(changed, first);
|
|
150
|
+
assert.equal(changed.messages[0].content, 'changed version with a different size');
|
|
151
|
+
} finally {
|
|
152
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
153
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
154
|
+
rmSync(root, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('session summaries use the first real user request and remove injected display blocks', () => {
|
|
159
|
+
const summary = _sessionSummary({
|
|
160
|
+
id: 'clean_title_source',
|
|
161
|
+
owner: 'user',
|
|
162
|
+
messages: [
|
|
163
|
+
{ role: 'user', content: '<system-reminder>runtime bootstrap</system-reminder>' },
|
|
164
|
+
{
|
|
165
|
+
role: 'user',
|
|
166
|
+
content: 'A previous model worked on this task and produced the compacted handoff summary below. Build on it.',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
role: 'user',
|
|
170
|
+
content: 'Reference files: [Image #1] <mcp-instructions>internal tools</mcp-instructions> Align the project dropdown',
|
|
171
|
+
},
|
|
172
|
+
{ role: 'user', content: 'The async agent task task_agent completed with internal output.' },
|
|
173
|
+
{ role: 'user', content: 'A later follow-up must not replace the stable title source' },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
assert.equal(summary.preview, 'Align the project dropdown');
|
|
178
|
+
});
|
|
179
|
+
|
|
102
180
|
test('authoritative summary refresh repairs a stale index and skips malformed session files', () => {
|
|
103
181
|
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-summary-'));
|
|
104
182
|
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
@@ -165,6 +243,10 @@ test('cached summaries reflect local lifecycle mutations and forced refresh reco
|
|
|
165
243
|
}));
|
|
166
244
|
|
|
167
245
|
assert.deepEqual(listStoredSessionSummaries().map((row) => row.id), ['indexed']);
|
|
246
|
+
assert.equal(
|
|
247
|
+
JSON.parse(readFileSync(join(root, 'session-summaries.json'), 'utf8')).version,
|
|
248
|
+
SESSION_SUMMARY_INDEX_VERSION,
|
|
249
|
+
);
|
|
168
250
|
|
|
169
251
|
saveSession({
|
|
170
252
|
id: 'cached_new', owner: 'user', updatedAt: 20,
|
|
@@ -351,7 +433,7 @@ test('authoritative refresh fails closed when the session directory is unreadabl
|
|
|
351
433
|
process.env.MIXDOG_DATA_DIR = root;
|
|
352
434
|
try {
|
|
353
435
|
writeFileSync(join(root, 'session-summaries.json'), JSON.stringify({
|
|
354
|
-
version:
|
|
436
|
+
version: SESSION_SUMMARY_INDEX_VERSION,
|
|
355
437
|
rows: [{
|
|
356
438
|
id: 'stale_desktop',
|
|
357
439
|
updatedAt: 1,
|
|
@@ -507,10 +589,87 @@ test('desktop resume pins projects and unclassified tasks to their host-managed
|
|
|
507
589
|
assert.equal(resolveResumeCwd({ cwd: '/cli' }, '/current'), '/cli');
|
|
508
590
|
});
|
|
509
591
|
|
|
592
|
+
test('new desktop sessions immediately repoint remote transcript forwarding', async () => {
|
|
593
|
+
let current = null;
|
|
594
|
+
const events = [];
|
|
595
|
+
const runtime = createLifecycleApi({
|
|
596
|
+
getSession: () => current,
|
|
597
|
+
setSession: (value) => { current = value; },
|
|
598
|
+
mgr: { closeSession: () => true },
|
|
599
|
+
invalidateContextStatusCache: () => events.push('invalidate'),
|
|
600
|
+
createCurrentSession: async () => {
|
|
601
|
+
events.push('create');
|
|
602
|
+
current = { id: 'desktop_new', messages: [] };
|
|
603
|
+
},
|
|
604
|
+
pushTranscriptRebind: () => events.push('rebind'),
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
assert.equal(await runtime.newSession(), 'desktop_new');
|
|
608
|
+
assert.deepEqual(events, ['invalidate', 'create', 'rebind']);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test('session deletion hard-deletes inactive rows and safely replaces the active row', async () => {
|
|
612
|
+
let current = {
|
|
613
|
+
id: 'desktop_active',
|
|
614
|
+
owner: 'user',
|
|
615
|
+
sourceType: 'cli',
|
|
616
|
+
messages: [{ role: 'user', content: 'Active session' }],
|
|
617
|
+
};
|
|
618
|
+
const rows = [
|
|
619
|
+
current,
|
|
620
|
+
{
|
|
621
|
+
id: 'desktop_inactive',
|
|
622
|
+
owner: 'user',
|
|
623
|
+
sourceType: 'cli',
|
|
624
|
+
messages: [{ role: 'user', content: 'Inactive session' }],
|
|
625
|
+
},
|
|
626
|
+
];
|
|
627
|
+
const events = [];
|
|
628
|
+
const runtime = createLifecycleApi({
|
|
629
|
+
getSession: () => current,
|
|
630
|
+
setSession: (value) => { current = value; },
|
|
631
|
+
mgr: {
|
|
632
|
+
listSessions: () => rows,
|
|
633
|
+
deleteSession: (id) => { events.push(['delete', id]); return true; },
|
|
634
|
+
closeSession: (...args) => { events.push(['close', ...args]); return true; },
|
|
635
|
+
},
|
|
636
|
+
cancelBackgroundTasks: (options) => events.push(['background', options]),
|
|
637
|
+
agentTool: { closeAll: (reason) => events.push(['agents', reason]) },
|
|
638
|
+
statusRoutes: { clearGatewaySessionRoute: (id) => events.push(['route', id]) },
|
|
639
|
+
invalidateContextStatusCache: () => events.push(['context']),
|
|
640
|
+
invalidatePreSessionToolSurface: () => events.push(['surface']),
|
|
641
|
+
createCurrentSession: async () => {
|
|
642
|
+
current = { id: 'desktop_replacement', messages: [] };
|
|
643
|
+
events.push(['create', current.id]);
|
|
644
|
+
},
|
|
645
|
+
pushTranscriptRebind: () => events.push(['rebind']),
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
assert.equal(await runtime.deleteSession('desktop_inactive'), true);
|
|
649
|
+
assert.deepEqual(events.shift(), ['delete', 'desktop_inactive']);
|
|
650
|
+
assert.equal(await runtime.deleteSession('desktop_active'), true);
|
|
651
|
+
assert.equal(current.id, 'desktop_replacement');
|
|
652
|
+
assert.deepEqual(events, [
|
|
653
|
+
['background', {
|
|
654
|
+
reason: 'desktop-session-delete',
|
|
655
|
+
notify: false,
|
|
656
|
+
callerSessionId: 'desktop_active',
|
|
657
|
+
}],
|
|
658
|
+
['agents', 'desktop-session-delete'],
|
|
659
|
+
['route', 'desktop_active'],
|
|
660
|
+
['close', 'desktop_active', 'desktop-session-delete', { tombstone: true }],
|
|
661
|
+
['context'],
|
|
662
|
+
['surface'],
|
|
663
|
+
['create', 'desktop_replacement'],
|
|
664
|
+
['rebind'],
|
|
665
|
+
]);
|
|
666
|
+
});
|
|
667
|
+
|
|
510
668
|
test('runtime resume returns the persisted transcript and restores desktop task scope', async () => {
|
|
511
669
|
let current = null;
|
|
512
670
|
let cwd = '/app/workspace';
|
|
513
671
|
let route = { provider: 'test', model: 'model' };
|
|
672
|
+
let transcriptRebinds = 0;
|
|
514
673
|
const messages = [
|
|
515
674
|
{ role: 'user', content: 'Persisted question' },
|
|
516
675
|
{ role: 'assistant', content: 'Persisted answer' },
|
|
@@ -548,13 +707,34 @@ test('runtime resume returns the persisted transcript and restores desktop task
|
|
|
548
707
|
applyResolvedCwd: (value) => { cwd = value; },
|
|
549
708
|
resolveRoute: (_config, value) => value,
|
|
550
709
|
applyDeferredToolSurface: () => {},
|
|
551
|
-
|
|
710
|
+
getStandaloneTools: () => [],
|
|
711
|
+
pushTranscriptRebind: () => { transcriptRebinds += 1; },
|
|
552
712
|
});
|
|
553
713
|
|
|
554
714
|
const resumed = await runtime.resume('desktop_task');
|
|
555
715
|
assert.deepEqual(resumed.messages, messages);
|
|
556
716
|
assert.equal(resumed.cwd, '/app/workspace');
|
|
557
717
|
assert.equal(current.cwd, '/app/workspace');
|
|
718
|
+
assert.equal(transcriptRebinds, 1);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test('runtime session prefetch warms the manager without changing the active session', () => {
|
|
722
|
+
const calls = [];
|
|
723
|
+
const current = { id: 'active', messages: [] };
|
|
724
|
+
const runtime = createLifecycleApi({
|
|
725
|
+
getSession: () => current,
|
|
726
|
+
getMode: () => 'full',
|
|
727
|
+
mgr: {
|
|
728
|
+
prefetchSession: (...args) => {
|
|
729
|
+
calls.push(args);
|
|
730
|
+
return true;
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
assert.equal(runtime.prefetchSession('next'), true);
|
|
736
|
+
assert.deepEqual(calls, [['next', 'full']]);
|
|
737
|
+
assert.equal(current.id, 'active');
|
|
558
738
|
});
|
|
559
739
|
|
|
560
740
|
test('desktop context switches retain runtime resources while durably closing the old session', async () => {
|
|
@@ -565,6 +745,7 @@ test('desktop context switches retain runtime resources while durably closing th
|
|
|
565
745
|
};
|
|
566
746
|
let cwd = '/old';
|
|
567
747
|
let desktopSession = { classification: 'task', projectPath: null };
|
|
748
|
+
let route = { provider: 'resumed-provider', model: 'resumed-model' };
|
|
568
749
|
const closed = [];
|
|
569
750
|
const cleanup = [];
|
|
570
751
|
let releaseMcp;
|
|
@@ -574,19 +755,29 @@ test('desktop context switches retain runtime resources while durably closing th
|
|
|
574
755
|
setSession: (value) => { current = value; },
|
|
575
756
|
getDesktopSession: () => desktopSession,
|
|
576
757
|
setDesktopSession: (value) => { desktopSession = value; },
|
|
758
|
+
getRoute: () => route,
|
|
759
|
+
setRoute: (value) => { route = value; },
|
|
760
|
+
getConfig: () => ({ default: 'workflow-lead' }),
|
|
577
761
|
getCurrentCwd: () => cwd,
|
|
578
762
|
mgr: { closeSession: (...args) => { closed.push(args); return true; } },
|
|
579
763
|
cancelBackgroundTasks: (options) => cleanup.push(['background', options]),
|
|
580
764
|
agentTool: { closeAll: (reason) => cleanup.push(['agents', reason]) },
|
|
581
765
|
statusRoutes: { clearGatewaySessionRoute: (id) => cleanup.push(['route', id]) },
|
|
582
|
-
applyResolvedCwd:
|
|
766
|
+
applyResolvedCwd: (value, options) => {
|
|
767
|
+
// Mirror the real contract: the cwd applies in place immediately while
|
|
768
|
+
// the project MCP reset continues in the background; the switch must not
|
|
769
|
+
// block on it.
|
|
583
770
|
cleanup.push(['cwd:start', value, options]);
|
|
584
|
-
await mcpReset;
|
|
585
771
|
cwd = value;
|
|
586
|
-
cleanup.push(['
|
|
772
|
+
void mcpReset.then(() => cleanup.push(['mcp:ready', value]));
|
|
773
|
+
return value;
|
|
587
774
|
},
|
|
588
775
|
invalidateContextStatusCache: () => {},
|
|
589
776
|
invalidatePreSessionToolSurface: () => {},
|
|
777
|
+
refreshRouteEffort: async () => {},
|
|
778
|
+
resolveRoute: (_config, value) => Object.keys(value).length > 0
|
|
779
|
+
? value
|
|
780
|
+
: { provider: 'configured-provider', model: 'configured-model' },
|
|
590
781
|
});
|
|
591
782
|
|
|
592
783
|
let settled = false;
|
|
@@ -595,8 +786,10 @@ test('desktop context switches retain runtime resources while durably closing th
|
|
|
595
786
|
desktopSession: { classification: 'project', projectPath: '/project' },
|
|
596
787
|
});
|
|
597
788
|
switching.then(() => { settled = true; });
|
|
598
|
-
|
|
599
|
-
|
|
789
|
+
// Non-blocking switch: it settles WITHOUT the MCP reset resolving. The
|
|
790
|
+
// reconnect gate moved to the ask path (bounded awaitInitialMcpConnect).
|
|
791
|
+
await switching;
|
|
792
|
+
assert.equal(settled, true);
|
|
600
793
|
assert.deepEqual(cleanup.slice(0, 4), [
|
|
601
794
|
['background', {
|
|
602
795
|
reason: 'desktop-context-switch',
|
|
@@ -605,17 +798,86 @@ test('desktop context switches retain runtime resources while durably closing th
|
|
|
605
798
|
}],
|
|
606
799
|
['agents', 'desktop-context-switch'],
|
|
607
800
|
['route', 'old'],
|
|
608
|
-
['cwd:start', '/project', { markRefresh: false
|
|
801
|
+
['cwd:start', '/project', { markRefresh: false }],
|
|
609
802
|
]);
|
|
610
803
|
releaseMcp();
|
|
611
|
-
await
|
|
804
|
+
await mcpReset;
|
|
612
805
|
|
|
613
806
|
assert.deepEqual(closed, [['old', 'desktop-context-switch', { tombstone: false }]]);
|
|
614
807
|
assert.equal(current, null);
|
|
615
808
|
assert.equal(cwd, '/project');
|
|
809
|
+
assert.deepEqual(route, { provider: 'configured-provider', model: 'configured-model' });
|
|
616
810
|
assert.deepEqual(desktopSession, { classification: 'project', projectPath: '/project' });
|
|
617
811
|
});
|
|
618
812
|
|
|
813
|
+
test('cleared desktop context resumes legacy rows without reviving the creation marker', async () => {
|
|
814
|
+
const taskDesktopSession = { classification: 'task', projectPath: null };
|
|
815
|
+
let current = null;
|
|
816
|
+
let cwd = '/task';
|
|
817
|
+
let desktopSession = taskDesktopSession;
|
|
818
|
+
const resumeOptions = [];
|
|
819
|
+
const stored = {
|
|
820
|
+
legacy: {
|
|
821
|
+
id: 'legacy',
|
|
822
|
+
provider: 'test',
|
|
823
|
+
model: 'model',
|
|
824
|
+
cwd: '/legacy',
|
|
825
|
+
desktopSession: null,
|
|
826
|
+
messages: [],
|
|
827
|
+
},
|
|
828
|
+
project: {
|
|
829
|
+
id: 'project',
|
|
830
|
+
provider: 'test',
|
|
831
|
+
model: 'model',
|
|
832
|
+
cwd: '/project',
|
|
833
|
+
desktopSession: { classification: 'project', projectPath: '/project' },
|
|
834
|
+
messages: [],
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
const runtime = createLifecycleApi({
|
|
838
|
+
getSession: () => current,
|
|
839
|
+
setSession: (value) => { current = value; },
|
|
840
|
+
getDesktopSession: () => desktopSession,
|
|
841
|
+
setDesktopSession: (value) => { desktopSession = value; },
|
|
842
|
+
getRoute: () => ({ provider: 'test', model: 'model' }),
|
|
843
|
+
setRoute: () => {},
|
|
844
|
+
getConfig: () => ({}),
|
|
845
|
+
getMode: () => 'full',
|
|
846
|
+
getCurrentCwd: () => cwd,
|
|
847
|
+
setSessionNeedsCwdRefresh: () => {},
|
|
848
|
+
desktopSession: taskDesktopSession,
|
|
849
|
+
mgr: {
|
|
850
|
+
closeSession: () => true,
|
|
851
|
+
resumeSession: async (id, _preset, options) => {
|
|
852
|
+
resumeOptions.push(options);
|
|
853
|
+
const session = stored[id];
|
|
854
|
+
const expected = options?.desktopSession;
|
|
855
|
+
if (expected && (!session.desktopSession
|
|
856
|
+
|| expected.classification !== session.desktopSession.classification)) return null;
|
|
857
|
+
return session;
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
statusRoutes: {},
|
|
861
|
+
createCurrentSession: async () => {},
|
|
862
|
+
refreshRouteEffort: async () => {},
|
|
863
|
+
invalidateContextStatusCache: () => {},
|
|
864
|
+
invalidatePreSessionToolSurface: () => {},
|
|
865
|
+
applyResolvedCwd: async (value) => { cwd = value; },
|
|
866
|
+
resolveRoute: (_config, value) => value,
|
|
867
|
+
applyDeferredToolSurface: () => {},
|
|
868
|
+
getStandaloneTools: () => [],
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
await runtime.switchContext({ cwd: '/legacy', desktopSession: null });
|
|
872
|
+
assert.equal(desktopSession, null);
|
|
873
|
+
assert.equal((await runtime.resume('legacy')).id, 'legacy');
|
|
874
|
+
assert.equal(resumeOptions[0], undefined);
|
|
875
|
+
|
|
876
|
+
await runtime.switchContext({ cwd: '/task', desktopSession: taskDesktopSession });
|
|
877
|
+
assert.equal(await runtime.resume('project'), null);
|
|
878
|
+
assert.deepEqual(resumeOptions[1], { desktopSession: taskDesktopSession });
|
|
879
|
+
});
|
|
880
|
+
|
|
619
881
|
test('desktop cwd application awaits project MCP reset before becoming ready', async () => {
|
|
620
882
|
const root = mkdtempSync(join(tmpdir(), 'mixdog-cwd-mcp-order-'));
|
|
621
883
|
const oldCwd = join(root, 'old');
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
const src = readFileSync("C:/Project/refs/opencode/packages/ui/src/components/icon.tsx", "utf8");
|
|
3
|
+
const re = /^\s*"?([a-z0-9-]+)"?:\s*`([^`]+)`,?\s*$/gm;
|
|
4
|
+
const entries = [];
|
|
5
|
+
let m;
|
|
6
|
+
while ((m = re.exec(src))) entries.push([m[1], m[2].replace(/\s+/g, " ").trim()]);
|
|
7
|
+
if (entries.length < 80) throw new Error(`only ${entries.length} icons parsed`);
|
|
8
|
+
const body = entries.map(([name, svg]) => ` ${JSON.stringify(name)}: ${JSON.stringify(svg)},`).join("\n");
|
|
9
|
+
const out = `// GENERATED from refs/opencode packages/ui/src/components/icon.tsx (MIT).\n// Regenerate with scripts/generate-oc-icons.mjs — do not hand-edit path data.\n// All glyphs draw on a 20x20 viewBox with currentColor strokes/fills.\nexport const OC_ICON_MARKUP: Record<string, string> = {\n${body}\n};\n`;
|
|
10
|
+
writeFileSync("apps/desktop/src/renderer/oc-icon-markup.ts", out);
|
|
11
|
+
console.log("icons:", entries.length);
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Integration test for src/tui/engine/live-share.mjs: a real owner pipe
|
|
2
|
+
// server and viewer client exchange full/delta/tail frames and submits over
|
|
3
|
+
// the platform transport (named pipe / unix socket).
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { createLiveShare } from '../src/tui/engine/live-share.mjs';
|
|
9
|
+
|
|
10
|
+
const PIPE_ID = `livetest_${process.pid}_${Date.now()}`;
|
|
11
|
+
const pipePath = process.platform === 'win32'
|
|
12
|
+
? `\\\\.\\pipe\\mixdog-live-${PIPE_ID}`
|
|
13
|
+
: join(tmpdir(), `mixdog-live-${PIPE_ID}.sock`);
|
|
14
|
+
|
|
15
|
+
function waitFor(check, label, timeoutMs = 4000) {
|
|
16
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
17
|
+
const startedAt = Date.now();
|
|
18
|
+
const tick = () => {
|
|
19
|
+
let result = false;
|
|
20
|
+
try { result = check(); } catch { result = false; }
|
|
21
|
+
if (result) return resolvePromise(result);
|
|
22
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
23
|
+
return rejectPromise(new Error(`timeout waiting for ${label}`));
|
|
24
|
+
}
|
|
25
|
+
setTimeout(tick, 20);
|
|
26
|
+
};
|
|
27
|
+
tick();
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createViewerStore() {
|
|
32
|
+
const store = {
|
|
33
|
+
items: [{ id: 'disk-only', kind: 'user', text: 'persisted last user message' }],
|
|
34
|
+
streamingTail: null,
|
|
35
|
+
spinner: null,
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
store,
|
|
39
|
+
apply: {
|
|
40
|
+
getState: () => store,
|
|
41
|
+
set: (patch) => Object.assign(store, patch),
|
|
42
|
+
replaceItems: (items) => { store.items = [...items]; },
|
|
43
|
+
patchItem: (id, patch) => {
|
|
44
|
+
const index = store.items.findIndex((item) => item?.id === id);
|
|
45
|
+
if (index < 0) return false;
|
|
46
|
+
store.items[index] = { ...store.items[index], ...patch };
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
appendItems: (items) => { store.items = [...store.items, ...items]; },
|
|
50
|
+
updateStreamingTail: (id, patch) => {
|
|
51
|
+
const current = store.streamingTail?.id === id ? store.streamingTail : { id, text: '' };
|
|
52
|
+
store.streamingTail = { ...current, ...patch, id };
|
|
53
|
+
},
|
|
54
|
+
clearStreamingTail: () => { store.streamingTail = null; },
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('live-share mirrors owner deltas and routes viewer submits', async () => {
|
|
60
|
+
const listeners = new Set();
|
|
61
|
+
let ownerState = {
|
|
62
|
+
items: [{ id: 'a1', kind: 'assistant', text: 'hello' }],
|
|
63
|
+
streamingTail: null,
|
|
64
|
+
spinner: null,
|
|
65
|
+
};
|
|
66
|
+
const receivedSubmits = [];
|
|
67
|
+
const owner = createLiveShare({
|
|
68
|
+
ownerSessionId: () => PIPE_ID,
|
|
69
|
+
viewerSessionId: () => '',
|
|
70
|
+
socketPathFor: () => pipePath,
|
|
71
|
+
getPublishedState: () => ownerState,
|
|
72
|
+
listeners,
|
|
73
|
+
onRemoteSubmit: (text) => receivedSubmits.push(text),
|
|
74
|
+
onOwnerClosed: () => {},
|
|
75
|
+
viewerApply: null,
|
|
76
|
+
});
|
|
77
|
+
const publish = (next) => {
|
|
78
|
+
ownerState = next;
|
|
79
|
+
for (const listener of listeners) listener();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const viewer = createViewerStore();
|
|
83
|
+
let ownerClosedCount = 0;
|
|
84
|
+
const viewerShare = createLiveShare({
|
|
85
|
+
ownerSessionId: () => '',
|
|
86
|
+
viewerSessionId: () => PIPE_ID,
|
|
87
|
+
socketPathFor: () => pipePath,
|
|
88
|
+
getPublishedState: () => ({ items: [], streamingTail: null, spinner: null }),
|
|
89
|
+
listeners: new Set(),
|
|
90
|
+
onRemoteSubmit: () => {},
|
|
91
|
+
onOwnerClosed: () => { ownerClosedCount += 1; },
|
|
92
|
+
viewerApply: viewer.apply,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
owner.ensure();
|
|
97
|
+
const initialSync = viewerShare.waitForViewerSync(PIPE_ID, 1_000);
|
|
98
|
+
await waitFor(() => {
|
|
99
|
+
viewerShare.ensure();
|
|
100
|
+
return viewerShare.viewerConnected();
|
|
101
|
+
}, 'viewer connect');
|
|
102
|
+
// Initial full frame mirrors the owner transcript.
|
|
103
|
+
assert.equal(await initialSync, true);
|
|
104
|
+
await waitFor(() => viewer.store.items.length === 1
|
|
105
|
+
&& viewer.store.items[0].id === 'a1', 'initial full frame');
|
|
106
|
+
assert.equal(viewer.store.items.some((item) => item.id === 'disk-only'), false,
|
|
107
|
+
'the initial sync barrier must replace the incomplete persisted transcript');
|
|
108
|
+
|
|
109
|
+
// Appended item + streaming tail start.
|
|
110
|
+
publish({
|
|
111
|
+
...ownerState,
|
|
112
|
+
items: [...ownerState.items, { id: 'u1', kind: 'user', text: 'hi from owner side' }],
|
|
113
|
+
streamingTail: { kind: 'assistant', id: 't1', text: 'stream', streaming: true },
|
|
114
|
+
spinner: { active: true, mode: 'responding' },
|
|
115
|
+
});
|
|
116
|
+
await waitFor(() => viewer.store.items.length === 2
|
|
117
|
+
&& viewer.store.streamingTail?.text === 'stream'
|
|
118
|
+
&& viewer.store.spinner?.active === true, 'append + tail frame');
|
|
119
|
+
|
|
120
|
+
// Append-only tail growth rides the suffix protocol.
|
|
121
|
+
publish({
|
|
122
|
+
...ownerState,
|
|
123
|
+
streamingTail: { kind: 'assistant', id: 't1', text: 'streaming more', streaming: true },
|
|
124
|
+
});
|
|
125
|
+
await waitFor(() => viewer.store.streamingTail?.text === 'streaming more', 'tail suffix frame');
|
|
126
|
+
|
|
127
|
+
// Patched item (owner edited an existing row) arrives as a change.
|
|
128
|
+
publish({
|
|
129
|
+
...ownerState,
|
|
130
|
+
items: [{ ...ownerState.items[0], text: 'hello edited' }, ownerState.items[1]],
|
|
131
|
+
streamingTail: null,
|
|
132
|
+
});
|
|
133
|
+
await waitFor(() => viewer.store.items[0].text === 'hello edited'
|
|
134
|
+
&& viewer.store.streamingTail === null, 'patch + tail clear frame');
|
|
135
|
+
|
|
136
|
+
// Viewer submit reaches the owner queue.
|
|
137
|
+
assert.equal(viewerShare.sendSubmit('typed on viewer'), true);
|
|
138
|
+
await waitFor(() => receivedSubmits.length === 1, 'viewer submit');
|
|
139
|
+
assert.equal(receivedSubmits[0], 'typed on viewer');
|
|
140
|
+
|
|
141
|
+
// Owner shutdown notifies the viewer promotion path.
|
|
142
|
+
owner.dispose();
|
|
143
|
+
await waitFor(() => ownerClosedCount === 1, 'owner close notification');
|
|
144
|
+
} finally {
|
|
145
|
+
owner.dispose();
|
|
146
|
+
viewerShare.dispose();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
@@ -107,7 +107,7 @@ async function main() {
|
|
|
107
107
|
const skipsReview = reviewSkipViolation(workflow);
|
|
108
108
|
assert(hasAll(lead, 'write-role agents self-verify', 'cross-scope verification', 'benches', 'all git', 'current project/workspace'), 'lead tool rules must preserve verification, git, and workspace ownership');
|
|
109
109
|
assert(hasAll(workflow, 'before the user explicitly approves the latest plan', 'no edits, no state mutation, no delegation'), 'default workflow must require latest-plan approval before work');
|
|
110
|
-
assert(hasAll(workflow, 'on approval, fan out at maximum width', 'one agent per independent scope', 'all spawned in one turn'), 'default workflow must fan out independent scopes at maximum width');
|
|
110
|
+
assert(hasAll(workflow, 'on approval, fan out at maximum width', 'one agent per independent scope', 'all spawned in one turn', 'split the plan into as many scopes as possible', 'disjoint file/module sets are independent', 'merge only on a true output dependency', 'prefer parallel scopes over sequential slices in one agent'), 'default workflow must fan out independent scopes at maximum width');
|
|
111
111
|
assert(hasAll(workflow, 'simple, well-understood implementation goes to worker', 'complex or investigative implementation goes to heavy worker', 'lead itself edits only a local, one-turn configuration/git change', 'debugger only on a defect needing deep root-cause analysis or a bug surviving 2+ review/fix cycles'), 'default workflow must enforce role routing and Lead/Debugger limits');
|
|
112
112
|
assert(hasAll(workflow, 'every implementation gets its own reviewer, attached per scope', 'only the local lead-direct edits above are exempt', 'lead itself edits only a local, one-turn configuration/git change', 'keep the same reviewer through the fix loop', 'fix -> re-verify until clean', 'lead cross-verifies'), 'default workflow must require a Reviewer for every implementation except local Lead-direct one-turn config/git edits');
|
|
113
113
|
assert(hasAll(workflow, 'build, deploy, commit, and push happen only on an explicit user request', 'on direction change, pause and re-consult the user'), 'default workflow must require user authorization and re-consultation');
|
|
@@ -36,6 +36,7 @@ function queuedProvider(responses, streamed = []) {
|
|
|
36
36
|
async function run(provider, messages = [{ role: 'user', content: 'answer fully' }], options = {}) {
|
|
37
37
|
return agentLoop(provider, messages, 'fake-model', [], options.onToolCall, process.cwd(), {
|
|
38
38
|
onTextDelta: options.onTextDelta,
|
|
39
|
+
takeAssistantTranscriptMetadata: options.takeAssistantTranscriptMetadata,
|
|
39
40
|
session: options.session,
|
|
40
41
|
providerState: options.providerState,
|
|
41
42
|
});
|
|
@@ -56,8 +57,17 @@ test('one max-output continuation resumes from preserved partial and returns one
|
|
|
56
57
|
{ content: 'omega', stopReason: 'end_turn' },
|
|
57
58
|
], streamed);
|
|
58
59
|
const messages = [{ role: 'user', content: 'answer fully' }];
|
|
60
|
+
const recoveryTranscriptMeta = {
|
|
61
|
+
at: 1_735_689_600_000,
|
|
62
|
+
model: 'Fake Model',
|
|
63
|
+
provider: 'fake-provider',
|
|
64
|
+
agent: 'Test Workflow',
|
|
65
|
+
};
|
|
59
66
|
|
|
60
|
-
const result = await run(provider, messages, {
|
|
67
|
+
const result = await run(provider, messages, {
|
|
68
|
+
onTextDelta: () => {},
|
|
69
|
+
takeAssistantTranscriptMetadata: () => recoveryTranscriptMeta,
|
|
70
|
+
});
|
|
61
71
|
|
|
62
72
|
assert.equal(provider.sent.length, 2);
|
|
63
73
|
assert.equal(result.content, 'alpha omega');
|
|
@@ -67,6 +77,7 @@ test('one max-output continuation resumes from preserved partial and returns one
|
|
|
67
77
|
assert.equal(result.terminationReason, undefined);
|
|
68
78
|
assert.deepEqual(provider.sent[1].map((message) => message.role), ['user', 'assistant', 'user']);
|
|
69
79
|
assert.equal(provider.sent[1][1].content, 'alpha ');
|
|
80
|
+
assert.deepEqual(provider.sent[1][1].meta?.transcript, recoveryTranscriptMeta);
|
|
70
81
|
assert.match(provider.sent[1][2].content, /Resume directly/);
|
|
71
82
|
assert.match(provider.sent[1][2].content, /no apology, no recap/i);
|
|
72
83
|
assert.equal(persistedAssistantText(messages, result), result.content);
|