mixdog 0.9.52 → 0.9.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/bench-run.mjs +2 -2
- package/scripts/compact-pressure-test.mjs +104 -0
- package/scripts/desktop-session-bridge-test.mjs +704 -0
- package/scripts/freevar-smoke.mjs +7 -4
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/lifecycle-api-test.mjs +65 -4
- package/scripts/max-output-recovery-test.mjs +31 -0
- package/scripts/memory-core-input-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +144 -4
- package/scripts/openai-ws-early-settle-test.mjs +40 -0
- package/scripts/parent-abort-link-test.mjs +24 -0
- package/scripts/process-lifecycle-test.mjs +91 -22
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +326 -11
- package/scripts/provider-toolcall-test.mjs +269 -27
- package/scripts/session-orphan-sweep-test.mjs +27 -1
- package/src/lib/keychain-cjs.cjs +36 -23
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
- package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +76 -325
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +10 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +68 -289
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +33 -5
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +113 -144
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +101 -190
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +263 -0
- package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
- package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
- package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -71
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +96 -12
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +15 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -81
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -20
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +65 -12
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
- package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
- package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
- package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
- package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
- package/src/runtime/channels/backends/discord.mjs +6 -6
- package/src/runtime/channels/lib/config.mjs +15 -2
- package/src/runtime/channels/lib/memory-client.mjs +20 -3
- package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
- package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
- package/src/runtime/channels/lib/worker-main.mjs +16 -5
- package/src/runtime/memory/index.mjs +24 -198
- package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
- package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
- package/src/runtime/memory/lib/query-handlers.mjs +2 -122
- package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
- package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
- package/src/runtime/search/lib/http-fetch.mjs +1 -1
- package/src/runtime/shared/config.mjs +58 -13
- package/src/runtime/shared/llm/cost.mjs +14 -4
- package/src/runtime/shared/memory-snapshot.mjs +245 -0
- package/src/runtime/shared/process-lifecycle.mjs +184 -34
- package/src/runtime/shared/process-shutdown.mjs +6 -0
- package/src/runtime/shared/resource-admission.mjs +7 -2
- package/src/session-runtime/channel-config-api.mjs +7 -7
- package/src/session-runtime/config-lifecycle.mjs +20 -17
- package/src/session-runtime/cwd-plugins.mjs +9 -7
- package/src/session-runtime/env.mjs +1 -2
- package/src/session-runtime/hitch-profile.mjs +45 -0
- package/src/session-runtime/lifecycle-api.mjs +36 -9
- package/src/session-runtime/mcp-glue.mjs +6 -11
- package/src/session-runtime/provider-init-key.mjs +17 -0
- package/src/session-runtime/runtime-core.mjs +44 -103
- package/src/session-runtime/runtime-paths.mjs +20 -0
- package/src/session-runtime/runtime-tool-routing.mjs +55 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/tool-catalog-data.mjs +51 -0
- package/src/session-runtime/tool-catalog.mjs +15 -89
- package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
- package/src/standalone/agent-tool/worker-rows.mjs +93 -0
- package/src/standalone/agent-tool.mjs +12 -162
- package/src/standalone/channel-admin.mjs +29 -0
- package/src/tui/App.jsx +11 -5
- package/src/tui/dist/index.mjs +233 -65
- package/src/tui/engine/session-api-ext.mjs +37 -4
- package/src/tui/engine/turn.mjs +31 -0
- package/src/tui/index.jsx +1 -0
- package/src/tui/lib/voice-setup.mjs +5 -5
- package/scripts/_devtools-stub.mjs +0 -1
- package/src/runtime/lib/keychain-cjs.cjs +0 -288
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
_normalizeSummaryIndex,
|
|
10
|
+
_sessionSummary,
|
|
11
|
+
} from '../src/runtime/agent/orchestrator/session/store-summary-index.mjs';
|
|
12
|
+
import { createLifecycleApi, resolveResumeCwd } from '../src/session-runtime/lifecycle-api.mjs';
|
|
13
|
+
import { createCwdPlugins } from '../src/session-runtime/cwd-plugins.mjs';
|
|
14
|
+
import {
|
|
15
|
+
normalizeDesktopSessionMetadata,
|
|
16
|
+
resumeSession as resumeStoredSession,
|
|
17
|
+
} from '../src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs';
|
|
18
|
+
import {
|
|
19
|
+
drainSessionStore,
|
|
20
|
+
deleteSession,
|
|
21
|
+
loadSession,
|
|
22
|
+
listStoredSessionSummaries,
|
|
23
|
+
markSessionClosed,
|
|
24
|
+
saveSession,
|
|
25
|
+
saveSessionAsync,
|
|
26
|
+
setLiveSession,
|
|
27
|
+
} from '../src/runtime/agent/orchestrator/session/store.mjs';
|
|
28
|
+
|
|
29
|
+
test('desktop classification is optional and round-trips through the existing summary index', () => {
|
|
30
|
+
const task = _sessionSummary({
|
|
31
|
+
id: 'lead_task',
|
|
32
|
+
owner: 'cli',
|
|
33
|
+
agent: 'lead',
|
|
34
|
+
cwd: '/app/workspace',
|
|
35
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
36
|
+
});
|
|
37
|
+
const project = _normalizeSummaryIndex({
|
|
38
|
+
rows: [{
|
|
39
|
+
id: 'lead_project',
|
|
40
|
+
cwd: '/project',
|
|
41
|
+
desktopSession: { classification: 'project', projectPath: '/project' },
|
|
42
|
+
}],
|
|
43
|
+
}).rows[0];
|
|
44
|
+
const legacy = _normalizeSummaryIndex({ rows: [{ id: 'legacy', cwd: '/old' }] }).rows[0];
|
|
45
|
+
const malformed = _normalizeSummaryIndex({
|
|
46
|
+
rows: [{ id: 'malformed', desktopSession: { classification: 'worker' } }],
|
|
47
|
+
}).rows[0];
|
|
48
|
+
|
|
49
|
+
assert.deepEqual(task.desktopSession, { classification: 'task', projectPath: null });
|
|
50
|
+
assert.deepEqual(project.desktopSession, { classification: 'project', projectPath: '/project' });
|
|
51
|
+
assert.equal(legacy.desktopSession, null);
|
|
52
|
+
assert.equal(malformed.desktopSession, null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('authoritative summary refresh repairs a stale index and skips malformed session files', () => {
|
|
56
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-summary-'));
|
|
57
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
58
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
59
|
+
try {
|
|
60
|
+
const sessions = join(root, 'sessions');
|
|
61
|
+
mkdirSync(sessions, { recursive: true });
|
|
62
|
+
writeFileSync(join(root, 'session-summaries.json'), JSON.stringify({
|
|
63
|
+
version: 1,
|
|
64
|
+
rows: [{
|
|
65
|
+
id: 'desktop_old',
|
|
66
|
+
updatedAt: 1,
|
|
67
|
+
cwd: '/old',
|
|
68
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
69
|
+
}],
|
|
70
|
+
}));
|
|
71
|
+
writeFileSync(join(sessions, 'desktop_new.json'), JSON.stringify({
|
|
72
|
+
id: 'desktop_new',
|
|
73
|
+
owner: 'user',
|
|
74
|
+
agent: 'lead',
|
|
75
|
+
updatedAt: 20,
|
|
76
|
+
cwd: '/app/workspace',
|
|
77
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
78
|
+
messages: [{ role: 'user', content: 'Newly persisted desktop task' }],
|
|
79
|
+
}));
|
|
80
|
+
writeFileSync(join(sessions, 'cli_only.json'), JSON.stringify({
|
|
81
|
+
id: 'cli_only',
|
|
82
|
+
owner: 'user',
|
|
83
|
+
updatedAt: 10,
|
|
84
|
+
cwd: '/cli',
|
|
85
|
+
messages: [{ role: 'user', content: 'CLI only' }],
|
|
86
|
+
}));
|
|
87
|
+
writeFileSync(join(sessions, 'broken.json'), '{"id":');
|
|
88
|
+
|
|
89
|
+
const rows = listStoredSessionSummaries({ refreshFromStorage: true });
|
|
90
|
+
assert.deepEqual(rows.map((row) => row.id), ['desktop_new', 'cli_only']);
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
rows.find((row) => row.id === 'desktop_new').desktopSession,
|
|
93
|
+
{ classification: 'task', projectPath: null },
|
|
94
|
+
);
|
|
95
|
+
assert.equal(rows.find((row) => row.id === 'cli_only').desktopSession, null);
|
|
96
|
+
const repaired = JSON.parse(readFileSync(join(root, 'session-summaries.json'), 'utf8'));
|
|
97
|
+
assert.deepEqual(repaired.rows.map((row) => row.id), ['desktop_new', 'cli_only']);
|
|
98
|
+
} finally {
|
|
99
|
+
drainSessionStore();
|
|
100
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
101
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
102
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 25 });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('cached summaries reflect local lifecycle mutations and forced refresh reconciles disk', async () => {
|
|
107
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-summary-cache-'));
|
|
108
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
109
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
110
|
+
try {
|
|
111
|
+
const sessions = join(root, 'sessions');
|
|
112
|
+
mkdirSync(sessions, { recursive: true });
|
|
113
|
+
writeFileSync(join(sessions, 'indexed.json'), JSON.stringify({
|
|
114
|
+
id: 'indexed', owner: 'user', updatedAt: 10, messages: [],
|
|
115
|
+
}));
|
|
116
|
+
writeFileSync(join(root, 'session-summaries.json'), JSON.stringify({
|
|
117
|
+
version: 1, rows: [{ id: 'indexed', owner: 'user', updatedAt: 10 }],
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
assert.deepEqual(listStoredSessionSummaries().map((row) => row.id), ['indexed']);
|
|
121
|
+
|
|
122
|
+
saveSession({
|
|
123
|
+
id: 'cached_new', owner: 'user', updatedAt: 20,
|
|
124
|
+
messages: [{ role: 'user', content: 'Cached local session' }],
|
|
125
|
+
}, { sync: true });
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
listStoredSessionSummaries().map((row) => row.id),
|
|
128
|
+
['cached_new', 'indexed'],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await saveSessionAsync({
|
|
132
|
+
id: 'cached_async', owner: 'user', updatedAt: 25,
|
|
133
|
+
messages: [{ role: 'user', content: 'Cached async session' }],
|
|
134
|
+
});
|
|
135
|
+
assert.equal(listStoredSessionSummaries().some((row) => row.id === 'cached_async'), true);
|
|
136
|
+
assert.equal(deleteSession('cached_async'), true);
|
|
137
|
+
|
|
138
|
+
assert.notEqual(markSessionClosed('cached_new', 'test'), null);
|
|
139
|
+
assert.equal(
|
|
140
|
+
listStoredSessionSummaries().find((row) => row.id === 'cached_new').closed,
|
|
141
|
+
true,
|
|
142
|
+
);
|
|
143
|
+
assert.equal(deleteSession('cached_new'), true);
|
|
144
|
+
assert.equal(listStoredSessionSummaries().some((row) => row.id === 'cached_new'), false);
|
|
145
|
+
|
|
146
|
+
writeFileSync(join(sessions, 'external.json'), JSON.stringify({
|
|
147
|
+
id: 'external', owner: 'user', updatedAt: 30, messages: [],
|
|
148
|
+
}));
|
|
149
|
+
assert.equal(listStoredSessionSummaries().some((row) => row.id === 'external'), false);
|
|
150
|
+
assert.deepEqual(
|
|
151
|
+
listStoredSessionSummaries({ refreshFromStorage: true }).map((row) => row.id),
|
|
152
|
+
['external', 'indexed'],
|
|
153
|
+
);
|
|
154
|
+
unlinkSync(join(sessions, 'external.json'));
|
|
155
|
+
assert.deepEqual(
|
|
156
|
+
listStoredSessionSummaries({ refreshFromStorage: true }).map((row) => row.id),
|
|
157
|
+
['indexed'],
|
|
158
|
+
);
|
|
159
|
+
} finally {
|
|
160
|
+
drainSessionStore();
|
|
161
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
162
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
163
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 25 });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('authoritative refresh trusts disk over stale live state while preserving unsettled local writes', async () => {
|
|
168
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-summary-races-'));
|
|
169
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
170
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
171
|
+
try {
|
|
172
|
+
const sessions = join(root, 'sessions');
|
|
173
|
+
mkdirSync(sessions, { recursive: true });
|
|
174
|
+
writeFileSync(join(sessions, 'authoritative.json'), JSON.stringify({
|
|
175
|
+
id: 'authoritative',
|
|
176
|
+
owner: 'user',
|
|
177
|
+
closed: true,
|
|
178
|
+
status: 'closed',
|
|
179
|
+
updatedAt: 40,
|
|
180
|
+
desktopSession: { classification: 'project', projectPath: '/disk-project' },
|
|
181
|
+
messages: [],
|
|
182
|
+
}));
|
|
183
|
+
setLiveSession({
|
|
184
|
+
id: 'authoritative',
|
|
185
|
+
owner: 'agent:stale',
|
|
186
|
+
closed: false,
|
|
187
|
+
updatedAt: 99,
|
|
188
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
189
|
+
messages: [],
|
|
190
|
+
});
|
|
191
|
+
let rows = listStoredSessionSummaries({ refreshFromStorage: true });
|
|
192
|
+
const authoritative = rows.find((row) => row.id === 'authoritative');
|
|
193
|
+
assert.equal(authoritative.closed, true);
|
|
194
|
+
assert.equal(authoritative.owner, 'user');
|
|
195
|
+
assert.deepEqual(authoritative.desktopSession, { classification: 'project', projectPath: '/disk-project' });
|
|
196
|
+
|
|
197
|
+
unlinkSync(join(sessions, 'authoritative.json'));
|
|
198
|
+
assert.equal(
|
|
199
|
+
listStoredSessionSummaries({ refreshFromStorage: true }).some((row) => row.id === 'authoritative'),
|
|
200
|
+
false,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
saveSession({
|
|
204
|
+
id: 'pending_desktop',
|
|
205
|
+
owner: 'user',
|
|
206
|
+
updatedAt: 50,
|
|
207
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
208
|
+
messages: [{ role: 'user', content: 'Visible before debounce flush' }],
|
|
209
|
+
});
|
|
210
|
+
assert.equal(existsSync(join(sessions, 'pending_desktop.json')), false);
|
|
211
|
+
rows = listStoredSessionSummaries({ refreshFromStorage: true });
|
|
212
|
+
assert.equal(rows.some((row) => row.id === 'pending_desktop'), true);
|
|
213
|
+
assert.equal(listStoredSessionSummaries().some((row) => row.id === 'pending_desktop'), true);
|
|
214
|
+
assert.equal(deleteSession('pending_desktop'), false);
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, 225));
|
|
216
|
+
assert.equal(existsSync(join(sessions, 'pending_desktop.json')), false);
|
|
217
|
+
assert.equal(
|
|
218
|
+
listStoredSessionSummaries({ refreshFromStorage: true }).some((row) => row.id === 'pending_desktop'),
|
|
219
|
+
false,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const first = saveSessionAsync({
|
|
223
|
+
id: 'worker_deleted',
|
|
224
|
+
owner: 'user',
|
|
225
|
+
updatedAt: 60,
|
|
226
|
+
messages: [{ role: 'user', content: 'First queued worker write' }],
|
|
227
|
+
});
|
|
228
|
+
const second = saveSessionAsync({
|
|
229
|
+
id: 'worker_deleted',
|
|
230
|
+
owner: 'user',
|
|
231
|
+
updatedAt: 61,
|
|
232
|
+
messages: [{ role: 'user', content: 'Latest queued worker write' }],
|
|
233
|
+
});
|
|
234
|
+
deleteSession('worker_deleted');
|
|
235
|
+
await Promise.all([first, second]);
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
237
|
+
assert.equal(existsSync(join(sessions, 'worker_deleted.json')), false);
|
|
238
|
+
assert.equal(
|
|
239
|
+
listStoredSessionSummaries({ refreshFromStorage: true }).some((row) => row.id === 'worker_deleted'),
|
|
240
|
+
false,
|
|
241
|
+
);
|
|
242
|
+
} finally {
|
|
243
|
+
drainSessionStore();
|
|
244
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
245
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
246
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 25 });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('authoritative refresh rejects filename and embedded session id mismatches', () => {
|
|
251
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-identity-'));
|
|
252
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
253
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
254
|
+
try {
|
|
255
|
+
const sessions = join(root, 'sessions');
|
|
256
|
+
mkdirSync(sessions, { recursive: true });
|
|
257
|
+
writeFileSync(join(sessions, 'cli_transcript.json'), JSON.stringify({
|
|
258
|
+
id: 'cli_transcript',
|
|
259
|
+
owner: 'user',
|
|
260
|
+
updatedAt: 10,
|
|
261
|
+
messages: [{ role: 'user', content: 'Legitimate CLI transcript' }],
|
|
262
|
+
}));
|
|
263
|
+
// Without filename/id validation this duplicate row could lend desktop
|
|
264
|
+
// metadata to cli_transcript, then resume the legitimate CLI-owned file.
|
|
265
|
+
writeFileSync(join(sessions, 'desktop_spoof.json'), JSON.stringify({
|
|
266
|
+
id: 'cli_transcript',
|
|
267
|
+
owner: 'user',
|
|
268
|
+
updatedAt: 30,
|
|
269
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
270
|
+
messages: [{ role: 'user', content: 'Spoofed desktop authorization' }],
|
|
271
|
+
}));
|
|
272
|
+
writeFileSync(join(sessions, 'desktop_good.json'), JSON.stringify({
|
|
273
|
+
id: 'desktop_good',
|
|
274
|
+
owner: 'user',
|
|
275
|
+
updatedAt: 20,
|
|
276
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
277
|
+
messages: [{ role: 'user', content: 'Legitimate desktop task' }],
|
|
278
|
+
}));
|
|
279
|
+
writeFileSync(join(sessions, 'duplicate_copy.json'), JSON.stringify({
|
|
280
|
+
id: 'desktop_good',
|
|
281
|
+
owner: 'user',
|
|
282
|
+
updatedAt: 40,
|
|
283
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
284
|
+
messages: [{ role: 'user', content: 'Mismatched duplicate' }],
|
|
285
|
+
}));
|
|
286
|
+
|
|
287
|
+
const rows = listStoredSessionSummaries({ refreshFromStorage: true });
|
|
288
|
+
assert.deepEqual(rows.map((row) => row.id), ['desktop_good', 'cli_transcript']);
|
|
289
|
+
assert.equal(rows.find((row) => row.id === 'desktop_good').preview, 'Legitimate desktop task');
|
|
290
|
+
assert.equal(rows.find((row) => row.id === 'cli_transcript').desktopSession, null);
|
|
291
|
+
assert.equal(loadSession('cli_transcript').messages[0].content, 'Legitimate CLI transcript');
|
|
292
|
+
assert.equal(loadSession('desktop_spoof'), null);
|
|
293
|
+
assert.equal(loadSession('duplicate_copy'), null);
|
|
294
|
+
} finally {
|
|
295
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
296
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
297
|
+
rmSync(root, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('authoritative refresh fails closed when the session directory is unreadable', () => {
|
|
302
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-unreadable-'));
|
|
303
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
304
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
305
|
+
try {
|
|
306
|
+
writeFileSync(join(root, 'session-summaries.json'), JSON.stringify({
|
|
307
|
+
version: 1,
|
|
308
|
+
rows: [{
|
|
309
|
+
id: 'stale_desktop',
|
|
310
|
+
updatedAt: 1,
|
|
311
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
312
|
+
}],
|
|
313
|
+
}));
|
|
314
|
+
// A non-directory at the authoritative storage path deterministically
|
|
315
|
+
// exercises readdir failure on Windows and POSIX.
|
|
316
|
+
writeFileSync(join(root, 'sessions'), 'not a directory');
|
|
317
|
+
|
|
318
|
+
assert.deepEqual(listStoredSessionSummaries({ refreshFromStorage: true }), []);
|
|
319
|
+
assert.deepEqual(
|
|
320
|
+
listStoredSessionSummaries({ rebuildIfMissing: false }).map((row) => row.id),
|
|
321
|
+
['stale_desktop'],
|
|
322
|
+
);
|
|
323
|
+
} finally {
|
|
324
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
325
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
326
|
+
rmSync(root, { recursive: true, force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('authoritative refresh includes a desktop session still in the debounce window', () => {
|
|
331
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-pending-'));
|
|
332
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
333
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
334
|
+
try {
|
|
335
|
+
saveSession({
|
|
336
|
+
id: 'desktop_pending',
|
|
337
|
+
owner: 'user',
|
|
338
|
+
agent: 'lead',
|
|
339
|
+
updatedAt: 50,
|
|
340
|
+
cwd: '/app/workspace',
|
|
341
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
342
|
+
messages: [{ role: 'user', content: 'Immediate desktop task' }],
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const rows = listStoredSessionSummaries({ refreshFromStorage: true });
|
|
346
|
+
assert.equal(rows.some((row) => row.id === 'desktop_pending'), true);
|
|
347
|
+
assert.equal(rows.find((row) => row.id === 'desktop_pending').preview, 'Immediate desktop task');
|
|
348
|
+
} finally {
|
|
349
|
+
drainSessionStore();
|
|
350
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
351
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
352
|
+
rmSync(root, { recursive: true, force: true });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('invalid existing disk identities block same-id pending and live state', () => {
|
|
357
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-live-blocked-'));
|
|
358
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
359
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
360
|
+
try {
|
|
361
|
+
const sessions = join(root, 'sessions');
|
|
362
|
+
mkdirSync(sessions, { recursive: true });
|
|
363
|
+
writeFileSync(join(sessions, 'pending_mismatch.json'), JSON.stringify({
|
|
364
|
+
id: 'different_transcript',
|
|
365
|
+
owner: 'user',
|
|
366
|
+
messages: [{ role: 'user', content: 'Mismatched disk transcript' }],
|
|
367
|
+
}));
|
|
368
|
+
writeFileSync(join(sessions, 'pending_malformed.json'), '{"id":');
|
|
369
|
+
|
|
370
|
+
for (const id of ['pending_mismatch', 'pending_malformed']) {
|
|
371
|
+
saveSession({
|
|
372
|
+
id,
|
|
373
|
+
owner: 'user',
|
|
374
|
+
agent: 'lead',
|
|
375
|
+
updatedAt: 60,
|
|
376
|
+
cwd: '/app/workspace',
|
|
377
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
378
|
+
messages: [{ role: 'user', content: `Pending replacement ${id}` }],
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const rows = listStoredSessionSummaries({ refreshFromStorage: true });
|
|
383
|
+
assert.equal(rows.some((row) => row.id === 'pending_mismatch'), false);
|
|
384
|
+
assert.equal(rows.some((row) => row.id === 'pending_malformed'), false);
|
|
385
|
+
assert.equal(loadSession('pending_mismatch'), null);
|
|
386
|
+
assert.equal(loadSession('pending_malformed'), null);
|
|
387
|
+
} finally {
|
|
388
|
+
drainSessionStore();
|
|
389
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
390
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
391
|
+
rmSync(root, { recursive: true, force: true });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('readFileSync failure blocks same-id pending and live state', () => {
|
|
396
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-read-error-'));
|
|
397
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
398
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
399
|
+
const readErrorPath = join(root, 'sessions', 'pending_read_error.json');
|
|
400
|
+
try {
|
|
401
|
+
mkdirSync(join(root, 'sessions'), { recursive: true });
|
|
402
|
+
// A directory with a .json storage name is visible to readdirSync and
|
|
403
|
+
// existsSync, but readFileSync itself fails on supported desktop platforms.
|
|
404
|
+
// This deterministically exercises the I/O catch before JSON.parse.
|
|
405
|
+
mkdirSync(readErrorPath);
|
|
406
|
+
saveSession({
|
|
407
|
+
id: 'pending_read_error',
|
|
408
|
+
owner: 'user',
|
|
409
|
+
agent: 'lead',
|
|
410
|
+
updatedAt: 70,
|
|
411
|
+
cwd: '/app/workspace',
|
|
412
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
413
|
+
messages: [{ role: 'user', content: 'Pending replacement after read error' }],
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const rows = listStoredSessionSummaries({ refreshFromStorage: true });
|
|
417
|
+
assert.equal(rows.some((row) => row.id === 'pending_read_error'), false);
|
|
418
|
+
assert.equal(loadSession('pending_read_error'), null);
|
|
419
|
+
} finally {
|
|
420
|
+
// Remove the deliberate directory collision so draining the pending test
|
|
421
|
+
// save can complete without changing production failure behavior.
|
|
422
|
+
rmSync(readErrorPath, { recursive: true, force: true });
|
|
423
|
+
drainSessionStore();
|
|
424
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
425
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
426
|
+
rmSync(root, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('session creation accepts only explicit desktop classification metadata', () => {
|
|
431
|
+
assert.deepEqual(
|
|
432
|
+
normalizeDesktopSessionMetadata({ classification: 'task', projectPath: '/ignored' }, '/cwd'),
|
|
433
|
+
{ classification: 'task', projectPath: null },
|
|
434
|
+
);
|
|
435
|
+
assert.deepEqual(
|
|
436
|
+
normalizeDesktopSessionMetadata({ classification: 'project' }, '/project'),
|
|
437
|
+
{ classification: 'project', projectPath: '/project' },
|
|
438
|
+
);
|
|
439
|
+
assert.deepEqual(
|
|
440
|
+
normalizeDesktopSessionMetadata({ classification: 'project', projectPath: ' /project/trimmed ' }, '/fallback'),
|
|
441
|
+
{ classification: 'project', projectPath: '/project/trimmed' },
|
|
442
|
+
);
|
|
443
|
+
assert.equal(normalizeDesktopSessionMetadata({ classification: 'project', projectPath: {} }, null), null);
|
|
444
|
+
assert.equal(normalizeDesktopSessionMetadata({ classification: 'project', projectPath: '\0bad' }, null), null);
|
|
445
|
+
assert.equal(normalizeDesktopSessionMetadata({ classification: 'project' }, null), null);
|
|
446
|
+
assert.equal(normalizeDesktopSessionMetadata({ classification: 'worker' }, '/cwd'), null);
|
|
447
|
+
assert.equal(normalizeDesktopSessionMetadata({}, '/cwd'), null);
|
|
448
|
+
assert.equal(normalizeDesktopSessionMetadata(null, '/cwd'), null);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('desktop resume pins projects and unclassified tasks to their host-managed scope', () => {
|
|
452
|
+
assert.equal(resolveResumeCwd({
|
|
453
|
+
cwd: '/stale',
|
|
454
|
+
desktopSession: { classification: 'project', projectPath: '/project' },
|
|
455
|
+
}, '/app/workspace'), '/project');
|
|
456
|
+
assert.equal(resolveResumeCwd({
|
|
457
|
+
cwd: '/transient',
|
|
458
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
459
|
+
}, '/app/workspace'), '/app/workspace');
|
|
460
|
+
assert.equal(resolveResumeCwd({ cwd: '/cli' }, '/current'), '/cli');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('runtime resume returns the persisted transcript and restores desktop task scope', async () => {
|
|
464
|
+
let current = null;
|
|
465
|
+
let cwd = '/app/workspace';
|
|
466
|
+
let route = { provider: 'test', model: 'model' };
|
|
467
|
+
const messages = [
|
|
468
|
+
{ role: 'user', content: 'Persisted question' },
|
|
469
|
+
{ role: 'assistant', content: 'Persisted answer' },
|
|
470
|
+
];
|
|
471
|
+
const runtime = createLifecycleApi({
|
|
472
|
+
getSession: () => current,
|
|
473
|
+
setSession: (value) => { current = value; },
|
|
474
|
+
getRoute: () => route,
|
|
475
|
+
setRoute: (value) => { route = value; },
|
|
476
|
+
getConfig: () => ({}),
|
|
477
|
+
getMode: () => 'full',
|
|
478
|
+
getCurrentCwd: () => cwd,
|
|
479
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
480
|
+
setSessionNeedsCwdRefresh: () => {},
|
|
481
|
+
mgr: {
|
|
482
|
+
resumeSession: async (id, _preset, options) => {
|
|
483
|
+
assert.deepEqual(options, {
|
|
484
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
id,
|
|
488
|
+
provider: 'test',
|
|
489
|
+
model: 'model',
|
|
490
|
+
cwd: '/stale-task-cwd',
|
|
491
|
+
desktopSession: { classification: 'task', projectPath: null },
|
|
492
|
+
messages,
|
|
493
|
+
};
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
statusRoutes: {},
|
|
497
|
+
createCurrentSession: async () => {},
|
|
498
|
+
refreshRouteEffort: async () => {},
|
|
499
|
+
invalidateContextStatusCache: () => {},
|
|
500
|
+
invalidatePreSessionToolSurface: () => {},
|
|
501
|
+
applyResolvedCwd: (value) => { cwd = value; },
|
|
502
|
+
resolveRoute: (_config, value) => value,
|
|
503
|
+
applyDeferredToolSurface: () => {},
|
|
504
|
+
standaloneTools: [],
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const resumed = await runtime.resume('desktop_task');
|
|
508
|
+
assert.deepEqual(resumed.messages, messages);
|
|
509
|
+
assert.equal(resumed.cwd, '/app/workspace');
|
|
510
|
+
assert.equal(current.cwd, '/app/workspace');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test('desktop context switches retain runtime resources while durably closing the old session', async () => {
|
|
514
|
+
let current = {
|
|
515
|
+
id: 'old',
|
|
516
|
+
messages: [{ role: 'user', content: 'keep me' }],
|
|
517
|
+
liveTurnMessages: [],
|
|
518
|
+
};
|
|
519
|
+
let cwd = '/old';
|
|
520
|
+
let desktopSession = { classification: 'task', projectPath: null };
|
|
521
|
+
const closed = [];
|
|
522
|
+
const cleanup = [];
|
|
523
|
+
let releaseMcp;
|
|
524
|
+
const mcpReset = new Promise((resolve) => { releaseMcp = resolve; });
|
|
525
|
+
const runtime = createLifecycleApi({
|
|
526
|
+
getSession: () => current,
|
|
527
|
+
setSession: (value) => { current = value; },
|
|
528
|
+
getDesktopSession: () => desktopSession,
|
|
529
|
+
setDesktopSession: (value) => { desktopSession = value; },
|
|
530
|
+
getCurrentCwd: () => cwd,
|
|
531
|
+
mgr: { closeSession: (...args) => { closed.push(args); return true; } },
|
|
532
|
+
cancelBackgroundTasks: (options) => cleanup.push(['background', options]),
|
|
533
|
+
agentTool: { closeAll: (reason) => cleanup.push(['agents', reason]) },
|
|
534
|
+
statusRoutes: { clearGatewaySessionRoute: (id) => cleanup.push(['route', id]) },
|
|
535
|
+
applyResolvedCwd: async (value, options) => {
|
|
536
|
+
cleanup.push(['cwd:start', value, options]);
|
|
537
|
+
await mcpReset;
|
|
538
|
+
cwd = value;
|
|
539
|
+
cleanup.push(['cwd:ready', value]);
|
|
540
|
+
},
|
|
541
|
+
invalidateContextStatusCache: () => {},
|
|
542
|
+
invalidatePreSessionToolSurface: () => {},
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
let settled = false;
|
|
546
|
+
const switching = runtime.switchContext({
|
|
547
|
+
cwd: '/project',
|
|
548
|
+
desktopSession: { classification: 'project', projectPath: '/project' },
|
|
549
|
+
});
|
|
550
|
+
switching.then(() => { settled = true; });
|
|
551
|
+
await Promise.resolve();
|
|
552
|
+
assert.equal(settled, false);
|
|
553
|
+
assert.deepEqual(cleanup.slice(0, 4), [
|
|
554
|
+
['background', {
|
|
555
|
+
reason: 'desktop-context-switch',
|
|
556
|
+
notify: false,
|
|
557
|
+
callerSessionId: 'old',
|
|
558
|
+
}],
|
|
559
|
+
['agents', 'desktop-context-switch'],
|
|
560
|
+
['route', 'old'],
|
|
561
|
+
['cwd:start', '/project', { markRefresh: false, waitForMcpReset: true }],
|
|
562
|
+
]);
|
|
563
|
+
releaseMcp();
|
|
564
|
+
await switching;
|
|
565
|
+
|
|
566
|
+
assert.deepEqual(closed, [['old', 'desktop-context-switch', { tombstone: false }]]);
|
|
567
|
+
assert.equal(current, null);
|
|
568
|
+
assert.equal(cwd, '/project');
|
|
569
|
+
assert.deepEqual(desktopSession, { classification: 'project', projectPath: '/project' });
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('desktop cwd application awaits project MCP reset before becoming ready', async () => {
|
|
573
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-cwd-mcp-order-'));
|
|
574
|
+
const oldCwd = join(root, 'old');
|
|
575
|
+
const nextCwd = join(root, 'next');
|
|
576
|
+
mkdirSync(oldCwd);
|
|
577
|
+
mkdirSync(nextCwd);
|
|
578
|
+
let currentCwd = oldCwd;
|
|
579
|
+
let projectKey = '';
|
|
580
|
+
let releaseReset;
|
|
581
|
+
const resetGate = new Promise((resolve) => { releaseReset = resolve; });
|
|
582
|
+
const events = [];
|
|
583
|
+
const cwdPlugins = createCwdPlugins({
|
|
584
|
+
getCurrentCwd: () => currentCwd,
|
|
585
|
+
setCurrentCwd: (value) => { currentCwd = value; },
|
|
586
|
+
getSession: () => null,
|
|
587
|
+
getLastProjectMcpKey: () => projectKey,
|
|
588
|
+
setLastProjectMcpKey: (value) => { projectKey = value; },
|
|
589
|
+
isCodeGraphPrewarmLazy: () => true,
|
|
590
|
+
isCodeGraphFirstTurnPrewarmDone: () => false,
|
|
591
|
+
getCodeGraphPrewarmDelayMs: () => 0,
|
|
592
|
+
connectConfiguredMcp: async (options) => {
|
|
593
|
+
events.push(['reset:start', options]);
|
|
594
|
+
await resetGate;
|
|
595
|
+
events.push(['reset:done']);
|
|
596
|
+
},
|
|
597
|
+
invalidatePreSessionToolSurface: () => events.push(['surface:invalidated']),
|
|
598
|
+
scheduleCodeGraphPrewarm: () => {},
|
|
599
|
+
hooks: { dispatch: () => {} },
|
|
600
|
+
hookCommonPayload: (value) => value,
|
|
601
|
+
bootProfile: () => {},
|
|
602
|
+
readProjectMcpServers: () => ({}),
|
|
603
|
+
writeLastSessionCwd: () => {},
|
|
604
|
+
clean: (value) => String(value || '').trim(),
|
|
605
|
+
resolve,
|
|
606
|
+
statSync,
|
|
607
|
+
});
|
|
608
|
+
try {
|
|
609
|
+
let ready = false;
|
|
610
|
+
const applying = cwdPlugins.applyResolvedCwd(nextCwd, { waitForMcpReset: true });
|
|
611
|
+
applying.then(() => { ready = true; });
|
|
612
|
+
await Promise.resolve();
|
|
613
|
+
assert.equal(ready, false);
|
|
614
|
+
assert.deepEqual(events, [['reset:start', { reset: true }]]);
|
|
615
|
+
releaseReset();
|
|
616
|
+
assert.equal(await applying, resolve(nextCwd));
|
|
617
|
+
assert.deepEqual(events, [
|
|
618
|
+
['reset:start', { reset: true }],
|
|
619
|
+
['reset:done'],
|
|
620
|
+
['surface:invalidated'],
|
|
621
|
+
]);
|
|
622
|
+
} finally {
|
|
623
|
+
rmSync(root, { recursive: true, force: true });
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('production runtime composition supplies mutable desktop session bindings', () => {
|
|
628
|
+
const source = readFileSync(
|
|
629
|
+
fileURLToPath(new URL('../src/session-runtime/runtime-core.mjs', import.meta.url)),
|
|
630
|
+
'utf8',
|
|
631
|
+
);
|
|
632
|
+
const composition = source.slice(
|
|
633
|
+
source.indexOf('const lifecycleApi = createLifecycleApi({'),
|
|
634
|
+
source.indexOf('const resourceApi = createResourceApi({'),
|
|
635
|
+
);
|
|
636
|
+
assert.match(composition, /getDesktopSession:\s*\(\)\s*=>\s*desktopSession/);
|
|
637
|
+
assert.match(composition, /setDesktopSession:\s*\(v\)\s*=>\s*\{\s*desktopSession = v;\s*\}/);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('summary metadata rejects non-string project paths instead of leaking objects', () => {
|
|
641
|
+
const withoutFallback = _sessionSummary({
|
|
642
|
+
id: 'bad_project_path',
|
|
643
|
+
desktopSession: { classification: 'project', projectPath: { untrusted: true } },
|
|
644
|
+
});
|
|
645
|
+
const withFallback = _normalizeSummaryIndex({
|
|
646
|
+
rows: [{
|
|
647
|
+
id: 'legacy_project_path',
|
|
648
|
+
cwd: ' /legacy/project ',
|
|
649
|
+
desktopSession: { classification: 'project', projectPath: null },
|
|
650
|
+
}],
|
|
651
|
+
}).rows[0];
|
|
652
|
+
|
|
653
|
+
assert.equal(withoutFallback.desktopSession, null);
|
|
654
|
+
assert.deepEqual(withFallback.desktopSession, {
|
|
655
|
+
classification: 'project',
|
|
656
|
+
projectPath: '/legacy/project',
|
|
657
|
+
});
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test('desktop-guarded resume refuses legacy/cross-class rows while the historical path still accepts legacy', async () => {
|
|
661
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-desktop-resume-guard-'));
|
|
662
|
+
const previousDataDir = process.env.MIXDOG_DATA_DIR;
|
|
663
|
+
process.env.MIXDOG_DATA_DIR = root;
|
|
664
|
+
try {
|
|
665
|
+
const sessions = join(root, 'sessions');
|
|
666
|
+
mkdirSync(sessions, { recursive: true });
|
|
667
|
+
const project = {
|
|
668
|
+
id: 'desktop_project_guard',
|
|
669
|
+
cwd: '/project',
|
|
670
|
+
desktopSession: { classification: 'project', projectPath: '/project' },
|
|
671
|
+
messages: [{ role: 'user', content: 'Project transcript' }],
|
|
672
|
+
tools: [],
|
|
673
|
+
};
|
|
674
|
+
const legacy = {
|
|
675
|
+
id: 'legacy_cli_guard',
|
|
676
|
+
cwd: '/cli',
|
|
677
|
+
messages: [{ role: 'user', content: 'CLI transcript' }],
|
|
678
|
+
tools: [],
|
|
679
|
+
};
|
|
680
|
+
writeFileSync(join(sessions, `${project.id}.json`), JSON.stringify(project));
|
|
681
|
+
writeFileSync(join(sessions, `${legacy.id}.json`), JSON.stringify(legacy));
|
|
682
|
+
|
|
683
|
+
const expectedTask = { desktopSession: { classification: 'task', projectPath: null } };
|
|
684
|
+
assert.equal(await resumeStoredSession(project.id, 'full', expectedTask), null);
|
|
685
|
+
assert.equal(await resumeStoredSession(legacy.id, 'full', expectedTask), null);
|
|
686
|
+
assert.deepEqual(JSON.parse(readFileSync(join(sessions, `${project.id}.json`), 'utf8')), project);
|
|
687
|
+
assert.deepEqual(JSON.parse(readFileSync(join(sessions, `${legacy.id}.json`), 'utf8')), legacy);
|
|
688
|
+
const resumedLegacy = await resumeStoredSession(legacy.id, 'full');
|
|
689
|
+
assert.equal(resumedLegacy.id, legacy.id);
|
|
690
|
+
assert.equal(resumedLegacy.desktopSession, undefined);
|
|
691
|
+
} finally {
|
|
692
|
+
drainSessionStore();
|
|
693
|
+
if (previousDataDir == null) delete process.env.MIXDOG_DATA_DIR;
|
|
694
|
+
else process.env.MIXDOG_DATA_DIR = previousDataDir;
|
|
695
|
+
try {
|
|
696
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 25 });
|
|
697
|
+
} catch {
|
|
698
|
+
// The save worker acknowledges the session write before its best-effort
|
|
699
|
+
// summary-index flush has fully released Windows filesystem handles.
|
|
700
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
701
|
+
rmSync(root, { recursive: true, force: true, maxRetries: 8, retryDelay: 25 });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
});
|