gestalt-mobile 0.18.4 → 0.18.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/client/assets/{index-Drd1F46y.css → index-CgSuVqbd.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/server/composition.contracts.js +2379 -0
- package/dist/server/server/composition.js +9 -1
- package/dist/server/server/features/autopilot/application/policy.js +3 -1
- package/dist/server/server/features/autopilot/application/service.js +53 -18
- package/dist/server/shared/contracts/org-plan-attention.js +17 -1
- package/package.json +10 -1
- /package/dist/client/assets/{index-DDsKyh4R.js → index-DoUX4ONm.js} +0 -0
|
@@ -0,0 +1,2379 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { once } from 'node:events';
|
|
7
|
+
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { describe, expect, it, onTestFinished, vi } from 'vitest';
|
|
12
|
+
import WebSocket from 'ws';
|
|
13
|
+
import { composeRelayApp } from './composition.js';
|
|
14
|
+
import { SqliteAuthorizationStore } from './platform/auth/sqlite-authorization-store.js';
|
|
15
|
+
import { authorizationSessionId, authorizedDeviceId, localOwnerId, webAuthnCredentialId, } from './features/auth/domain/identifiers.js';
|
|
16
|
+
import { deviceNickname } from './features/auth/domain/device-nickname.js';
|
|
17
|
+
import { workspaceId } from './platform/catalog/workspace-id.js';
|
|
18
|
+
import { planStatusDirectoryPath, planStatusFilePath, } from './platform/plans/filesystem-plan-status-source.js';
|
|
19
|
+
import { toOrgPlanAttentionToolResponse } from '../shared/contracts/org-plan-attention.js';
|
|
20
|
+
function fakeAppServer(calls) {
|
|
21
|
+
return {
|
|
22
|
+
rpc: {
|
|
23
|
+
request: async (method, params) => {
|
|
24
|
+
calls.push(method);
|
|
25
|
+
if (method === 'thread/start')
|
|
26
|
+
return { thread: { id: 'thread-1' } };
|
|
27
|
+
if (method === 'model/list')
|
|
28
|
+
return { data: [{ id: 'gpt-5.6-terra' }] };
|
|
29
|
+
if (method === 'skills/list')
|
|
30
|
+
return {
|
|
31
|
+
data: [{ cwd: params.cwds[0], skills: [], errors: [] }],
|
|
32
|
+
};
|
|
33
|
+
return {};
|
|
34
|
+
},
|
|
35
|
+
onNotification: () => () => { },
|
|
36
|
+
onServerRequest: () => () => { },
|
|
37
|
+
},
|
|
38
|
+
close: () => { },
|
|
39
|
+
onExit: () => () => { },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function liveAppServer(handles) {
|
|
43
|
+
return () => {
|
|
44
|
+
const handle = { calls: [], requests: [] };
|
|
45
|
+
handles.push(handle);
|
|
46
|
+
return {
|
|
47
|
+
rpc: {
|
|
48
|
+
request: async (method, params) => {
|
|
49
|
+
handle.calls.push(method);
|
|
50
|
+
handle.requests.push({ method, params });
|
|
51
|
+
if (method === 'thread/start')
|
|
52
|
+
return { thread: { id: `thread-${handles.length}` } };
|
|
53
|
+
if (method === 'turn/start')
|
|
54
|
+
return { turn: { id: `turn-${handle.calls.length}` } };
|
|
55
|
+
if (method === 'thread/read')
|
|
56
|
+
return { thread: { turns: [] } };
|
|
57
|
+
if (method === 'model/list')
|
|
58
|
+
return { data: [{ id: 'gpt-5.6-terra' }] };
|
|
59
|
+
if (method === 'skills/list')
|
|
60
|
+
return {
|
|
61
|
+
data: [{ cwd: params.cwds[0], skills: [], errors: [] }],
|
|
62
|
+
};
|
|
63
|
+
return {};
|
|
64
|
+
},
|
|
65
|
+
onNotification: (listener) => {
|
|
66
|
+
handle.notify = listener;
|
|
67
|
+
return () => { };
|
|
68
|
+
},
|
|
69
|
+
onServerRequest: (listener) => {
|
|
70
|
+
handle.request = listener;
|
|
71
|
+
return () => { };
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
close: () => { },
|
|
75
|
+
onExit: () => () => { },
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
async function createComposedSession(app) {
|
|
80
|
+
const workspace = (await app.inject('/api/bootstrap'))
|
|
81
|
+
.json()
|
|
82
|
+
.workspaces[0]?.children.find((item) => item.name === 'workspace');
|
|
83
|
+
expect(workspace).toBeDefined();
|
|
84
|
+
const created = await app.inject({
|
|
85
|
+
method: 'POST',
|
|
86
|
+
url: '/api/sessions',
|
|
87
|
+
payload: { workspaceId: workspace.id, profile: 'default' },
|
|
88
|
+
});
|
|
89
|
+
expect(created.statusCode).toBe(202);
|
|
90
|
+
return created.json().id;
|
|
91
|
+
}
|
|
92
|
+
const autopilotPlanText = (childState = 'TODO') => `#+TITLE: Autopilot fixture
|
|
93
|
+
* WIP [#A] Parent
|
|
94
|
+
:PROPERTIES:
|
|
95
|
+
:ID: parent
|
|
96
|
+
:SKILLS: $gestalt:org-plan
|
|
97
|
+
:REVIEW_STATUS: UNREVIEWED
|
|
98
|
+
:END:
|
|
99
|
+
- Effort :: Small
|
|
100
|
+
- Goal :: Keep moving.
|
|
101
|
+
- Notes :: Coordinator fixture.
|
|
102
|
+
** ${childState} [#A] Child
|
|
103
|
+
:PROPERTIES:
|
|
104
|
+
:ID: child
|
|
105
|
+
:END:
|
|
106
|
+
- Why :: Exercise the coordinator.
|
|
107
|
+
- Change :: Publish a safe state.
|
|
108
|
+
- Tests :: Exercise production routes.
|
|
109
|
+
- Done when :: The plan remains incomplete.
|
|
110
|
+
`;
|
|
111
|
+
const completedAutopilotPlanText = () => autopilotPlanText('DONE')
|
|
112
|
+
.replace('* WIP [#A] Parent', '* DONE [#A] Parent')
|
|
113
|
+
.replace(':REVIEW_STATUS: UNREVIEWED', ':REVIEW_STATUS: REVIEWED');
|
|
114
|
+
async function installAutopilotPlan(app, sessionId, workspacePath, name, childState = 'TODO') {
|
|
115
|
+
const planPath = join(workspacePath, `${name}.org`);
|
|
116
|
+
await writeFile(planPath, autopilotPlanText(childState));
|
|
117
|
+
await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), planPath), JSON.stringify({
|
|
118
|
+
schemaVersion: 1,
|
|
119
|
+
planPath,
|
|
120
|
+
reason: 'supervision-start',
|
|
121
|
+
updatedAt: new Date().toISOString(),
|
|
122
|
+
}));
|
|
123
|
+
await expect
|
|
124
|
+
.poll(async () => (await app.inject(`/api/sessions/${sessionId}/plan`)).statusCode)
|
|
125
|
+
.toBe(200);
|
|
126
|
+
expect((await app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/restore` })).statusCode).toBe(200);
|
|
127
|
+
return planPath;
|
|
128
|
+
}
|
|
129
|
+
async function createProductionAutopilotFixture(overrides = {}) {
|
|
130
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
131
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
132
|
+
ownTemporaryPaths(root, dataDir);
|
|
133
|
+
const workspacePath = join(root, 'workspace');
|
|
134
|
+
await mkdir(workspacePath);
|
|
135
|
+
const handles = [];
|
|
136
|
+
const app = await composeAuthorizedApp({
|
|
137
|
+
root,
|
|
138
|
+
dataDir,
|
|
139
|
+
relyingParty,
|
|
140
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
141
|
+
startAppServers: true,
|
|
142
|
+
launchAppServer: liveAppServer(handles),
|
|
143
|
+
profiles: {
|
|
144
|
+
list: async () => [],
|
|
145
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
146
|
+
},
|
|
147
|
+
...overrides,
|
|
148
|
+
});
|
|
149
|
+
const sessionId = await createComposedSession(app);
|
|
150
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
151
|
+
state: 'ready',
|
|
152
|
+
threadId: expect.any(String),
|
|
153
|
+
}));
|
|
154
|
+
await installAutopilotPlan(app, sessionId, workspacePath, 'autopilot');
|
|
155
|
+
return { app, dataDir, handles, root, sessionId, workspacePath };
|
|
156
|
+
}
|
|
157
|
+
function attentionCall(id, reason = 'hardBlock') {
|
|
158
|
+
return {
|
|
159
|
+
id,
|
|
160
|
+
method: 'item/tool/call',
|
|
161
|
+
params: {
|
|
162
|
+
tool: 'gestalt_org_plan_attention',
|
|
163
|
+
arguments: {
|
|
164
|
+
reason,
|
|
165
|
+
summary: 'A bounded human decision is required.',
|
|
166
|
+
requestedAction: 'Provide the requested decision.',
|
|
167
|
+
resumeCondition: reason === 'permissionRequired' ? 'permissionGranted' : 'externalStateChanged',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function ownTemporaryPaths(...paths) {
|
|
173
|
+
onTestFinished(async () => {
|
|
174
|
+
await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true })));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const compositionConcern = process.env.GESTALT_COMPOSITION_CONCERN;
|
|
178
|
+
function describeCompositionConcern(concern, callback) {
|
|
179
|
+
if (compositionConcern === concern)
|
|
180
|
+
describe(concern, callback);
|
|
181
|
+
}
|
|
182
|
+
const relyingParty = {
|
|
183
|
+
publicOrigin: 'http://localhost:3000',
|
|
184
|
+
rpId: 'localhost',
|
|
185
|
+
rpName: 'Gestalt Mobile',
|
|
186
|
+
};
|
|
187
|
+
async function composeAuthorizedApp(options) {
|
|
188
|
+
const homeDirectory = options.homeDirectory ?? (await mkdtemp(join(tmpdir(), 'gestalt-mobile-home-')));
|
|
189
|
+
if (!options.homeDirectory)
|
|
190
|
+
ownTemporaryPaths(homeDirectory);
|
|
191
|
+
const store = new SqliteAuthorizationStore(homeDirectory, options.relyingParty);
|
|
192
|
+
const owner = { id: localOwnerId('local-owner'), userHandle: new Uint8Array(32).fill(1) };
|
|
193
|
+
store.initializeOwner(owner.userHandle);
|
|
194
|
+
const device = {
|
|
195
|
+
id: authorizedDeviceId('test-device'),
|
|
196
|
+
credentialId: webAuthnCredentialId('test-credential'),
|
|
197
|
+
publicKey: new Uint8Array([1]),
|
|
198
|
+
counter: 0,
|
|
199
|
+
transports: ['internal'],
|
|
200
|
+
deviceType: 'singleDevice',
|
|
201
|
+
backedUp: false,
|
|
202
|
+
nickname: deviceNickname('Test device'),
|
|
203
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
204
|
+
};
|
|
205
|
+
store.claimFirstDevice(owner, device);
|
|
206
|
+
if (!store.sessionDevice(authorizationSessionId('test-session'), '2026-08-02T00:00:00.000Z'))
|
|
207
|
+
store.saveSession(authorizationSessionId('test-session'), {
|
|
208
|
+
deviceId: device.id,
|
|
209
|
+
expiresAt: '2026-09-01T00:00:00.000Z',
|
|
210
|
+
});
|
|
211
|
+
store.close();
|
|
212
|
+
const app = await composeRelayApp({ ...options, homeDirectory });
|
|
213
|
+
const inject = app.inject.bind(app);
|
|
214
|
+
app.inject = ((request) => {
|
|
215
|
+
if (typeof request === 'string')
|
|
216
|
+
return inject({ url: request, headers: { cookie: 'gestalt_mobile_session=test-session' } });
|
|
217
|
+
return inject({
|
|
218
|
+
...request,
|
|
219
|
+
headers: {
|
|
220
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
221
|
+
...(request.method && request.method !== 'GET'
|
|
222
|
+
? { origin: options.relyingParty.publicOrigin }
|
|
223
|
+
: {}),
|
|
224
|
+
...request.headers,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
return app;
|
|
229
|
+
}
|
|
230
|
+
async function createUnauthorizedProductionApp(root, dataDir) {
|
|
231
|
+
const homeDirectory = await mkdtemp(join(tmpdir(), 'gestalt-mobile-home-'));
|
|
232
|
+
ownTemporaryPaths(homeDirectory);
|
|
233
|
+
return composeRelayApp({
|
|
234
|
+
root,
|
|
235
|
+
dataDir,
|
|
236
|
+
homeDirectory,
|
|
237
|
+
relyingParty,
|
|
238
|
+
profiles: {
|
|
239
|
+
list: async () => [],
|
|
240
|
+
require: async () => ({
|
|
241
|
+
name: 'default',
|
|
242
|
+
state: 'ok',
|
|
243
|
+
status: 'ready',
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
installedCodexVersion: null,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
describe('production composition', () => {
|
|
250
|
+
describeCompositionConcern('autopilot', () => {
|
|
251
|
+
it('autopilot production composition keeps disabled sessions free of timers, reads, polls, and leaks', async () => {
|
|
252
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
253
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
254
|
+
ownTemporaryPaths(root, dataDir);
|
|
255
|
+
await mkdir(join(root, 'workspace'));
|
|
256
|
+
const calls = [];
|
|
257
|
+
const app = await composeAuthorizedApp({
|
|
258
|
+
root,
|
|
259
|
+
dataDir,
|
|
260
|
+
relyingParty,
|
|
261
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
262
|
+
startAppServers: true,
|
|
263
|
+
launchAppServer: () => fakeAppServer(calls),
|
|
264
|
+
profiles: {
|
|
265
|
+
list: async () => [],
|
|
266
|
+
require: async () => ({
|
|
267
|
+
name: 'default',
|
|
268
|
+
state: 'ok',
|
|
269
|
+
status: 'ready',
|
|
270
|
+
}),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
274
|
+
expect(calls).toEqual([]);
|
|
275
|
+
await app.close();
|
|
276
|
+
});
|
|
277
|
+
it('autopilot production composition authenticates concurrent toggles, schedules once, replays redacted audit, and keeps get/list safe', async () => {
|
|
278
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
279
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
280
|
+
ownTemporaryPaths(root, dataDir);
|
|
281
|
+
const workspacePath = join(root, 'workspace');
|
|
282
|
+
await mkdir(workspacePath);
|
|
283
|
+
const handles = [];
|
|
284
|
+
const app = await composeAuthorizedApp({
|
|
285
|
+
root,
|
|
286
|
+
dataDir,
|
|
287
|
+
relyingParty,
|
|
288
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
289
|
+
startAppServers: true,
|
|
290
|
+
launchAppServer: liveAppServer(handles),
|
|
291
|
+
profiles: {
|
|
292
|
+
list: async () => [],
|
|
293
|
+
require: async () => ({
|
|
294
|
+
name: 'default',
|
|
295
|
+
state: 'ok',
|
|
296
|
+
status: 'ready',
|
|
297
|
+
}),
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
const sessionId = await createComposedSession(app);
|
|
301
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
302
|
+
state: 'ready',
|
|
303
|
+
threadId: expect.any(String),
|
|
304
|
+
}));
|
|
305
|
+
// An authenticated request cannot opt in until a retained incomplete plan exists.
|
|
306
|
+
expect((await app.inject({
|
|
307
|
+
method: 'PUT',
|
|
308
|
+
url: `/api/sessions/${sessionId}/autopilot`,
|
|
309
|
+
payload: { enabled: true },
|
|
310
|
+
})).json()).toEqual({ code: 'AUTOPILOT_PLAN_REQUIRED' });
|
|
311
|
+
const planPath = join(workspacePath, 'autopilot.org');
|
|
312
|
+
await writeFile(planPath, `#+TITLE: Autopilot fixture
|
|
313
|
+
* WIP [#A] Parent
|
|
314
|
+
:PROPERTIES:
|
|
315
|
+
:ID: parent
|
|
316
|
+
:SKILLS: $gestalt:org-plan
|
|
317
|
+
:REVIEW_STATUS: UNREVIEWED
|
|
318
|
+
:END:
|
|
319
|
+
- Effort :: Small
|
|
320
|
+
- Goal :: Keep moving.
|
|
321
|
+
- Notes :: Coordinator fixture.
|
|
322
|
+
** TODO [#A] Child
|
|
323
|
+
:PROPERTIES:
|
|
324
|
+
:ID: child
|
|
325
|
+
:END:
|
|
326
|
+
- Why :: Exercise the coordinator.
|
|
327
|
+
- Change :: Publish a safe state.
|
|
328
|
+
- Tests :: Exercise production routes.
|
|
329
|
+
- Done when :: The plan remains incomplete.
|
|
330
|
+
`);
|
|
331
|
+
const opened = await app.inject({
|
|
332
|
+
method: 'PUT',
|
|
333
|
+
url: `/api/sessions/${sessionId}/plan`,
|
|
334
|
+
payload: { planName: 'autopilot.org' },
|
|
335
|
+
});
|
|
336
|
+
expect(opened.statusCode).toBe(200);
|
|
337
|
+
expect(opened.json()).toMatchObject({ title: 'Autopilot fixture', allDone: false });
|
|
338
|
+
expect((await app.inject(`/api/sessions/${sessionId}/plan`)).statusCode).toBe(200);
|
|
339
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
340
|
+
const address = app.server.address();
|
|
341
|
+
if (!address || typeof address === 'string')
|
|
342
|
+
throw new Error('Expected TCP listener');
|
|
343
|
+
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
|
|
344
|
+
headers: {
|
|
345
|
+
origin: relyingParty.publicOrigin,
|
|
346
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
const messages = [];
|
|
350
|
+
socket.on('message', (data) => messages.push(JSON.parse(String(data))));
|
|
351
|
+
await once(socket, 'open');
|
|
352
|
+
expect((await app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/restore` }))
|
|
353
|
+
.statusCode).toBe(200);
|
|
354
|
+
const enabled = await app.inject({
|
|
355
|
+
method: 'PUT',
|
|
356
|
+
url: `/api/sessions/${sessionId}/autopilot`,
|
|
357
|
+
payload: { enabled: true },
|
|
358
|
+
});
|
|
359
|
+
expect(enabled.statusCode).toBe(200);
|
|
360
|
+
expect(enabled.json()).toMatchObject({ autopilot: { enabled: true } });
|
|
361
|
+
await vi.waitFor(() => expect(messages.some((message) => message.event.type === 'autopilot.updated')).toBe(true));
|
|
362
|
+
expect(messages.filter((message) => message.event.type === 'autopilot.updated').at(-1)?.event
|
|
363
|
+
.payload).toMatchObject({ enabled: true });
|
|
364
|
+
// This is production composition, not a coordinator fake: the scheduler reaches the
|
|
365
|
+
// real runtime adapter and can pass only the fixed policy-owned prompt and opaque ID.
|
|
366
|
+
await vi.waitFor(() => expect(handles.at(-1)?.calls.filter((call) => call === 'turn/start')).toHaveLength(1), { timeout: 2_500 });
|
|
367
|
+
const automaticStart = handles.at(-1)?.requests.find((call) => call.method === 'turn/start');
|
|
368
|
+
expect(automaticStart?.params).toEqual({
|
|
369
|
+
threadId: expect.any(String),
|
|
370
|
+
input: [
|
|
371
|
+
{
|
|
372
|
+
type: 'text',
|
|
373
|
+
text: 'Inspect the active supervised Org Plan. Invoke gestalt_org_plan_attention only for a decision-table blocker; otherwise immediately perform the next legal lifecycle action. Do not send a status-only response.',
|
|
374
|
+
text_elements: [],
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
clientUserMessageId: expect.stringMatching(/^autopilot-\d+-[a-f0-9]{16}$/),
|
|
378
|
+
model: 'gpt-5.6-terra',
|
|
379
|
+
});
|
|
380
|
+
// The journal commits before the runtime request resolves, while the WebSocket transport
|
|
381
|
+
// delivers the committed events on its own turn of the event loop. Observe the durable
|
|
382
|
+
// boundary rather than assuming a synchronous socket delivery after `turn/start`.
|
|
383
|
+
await vi.waitFor(() => {
|
|
384
|
+
expect(messages.some((message) => message.event.type === 'autopilot.control-issued')).toBe(true);
|
|
385
|
+
expect(messages.some((message) => message.event.type === 'autopilot.turn-started')).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
const updates = messages.filter((message) => message.event.type === 'autopilot.updated');
|
|
388
|
+
await Promise.all([true, true].map(() => app.inject({
|
|
389
|
+
method: 'PUT',
|
|
390
|
+
url: `/api/sessions/${sessionId}/autopilot`,
|
|
391
|
+
payload: { enabled: true },
|
|
392
|
+
})));
|
|
393
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
394
|
+
expect(messages.filter((message) => message.event.type === 'autopilot.updated')).toHaveLength(updates.length);
|
|
395
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
396
|
+
autopilot: { enabled: true },
|
|
397
|
+
});
|
|
398
|
+
expect((await app.inject('/api/sessions')).json()).toEqual(expect.arrayContaining([
|
|
399
|
+
expect.objectContaining({ id: sessionId, autopilot: expect.any(Object) }),
|
|
400
|
+
]));
|
|
401
|
+
expect(JSON.stringify(await app.inject(`/api/sessions/${sessionId}`))).not.toContain('Inspect the active supervised Org Plan');
|
|
402
|
+
expect((await app.inject({
|
|
403
|
+
method: 'PUT',
|
|
404
|
+
url: `/api/sessions/${sessionId}/autopilot`,
|
|
405
|
+
payload: { enabled: false },
|
|
406
|
+
})).json()).toMatchObject({ autopilot: { enabled: false, state: 'disabled' } });
|
|
407
|
+
socket.close();
|
|
408
|
+
const replayed = [];
|
|
409
|
+
const replay = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
|
|
410
|
+
headers: {
|
|
411
|
+
origin: relyingParty.publicOrigin,
|
|
412
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
replay.on('message', (data) => replayed.push(JSON.parse(String(data))));
|
|
416
|
+
await once(replay, 'open');
|
|
417
|
+
await vi.waitFor(() => expect(replayed.some((message) => message.event?.type === 'autopilot.updated')).toBe(true));
|
|
418
|
+
const sequence = replayed.flatMap((message) => message.event ? [message.event.sequence] : []);
|
|
419
|
+
expect(new Set(sequence).size).toBe(sequence.length);
|
|
420
|
+
expect(JSON.stringify(replayed)).not.toContain('Inspect the active supervised Org Plan');
|
|
421
|
+
replay.close();
|
|
422
|
+
await app.close();
|
|
423
|
+
});
|
|
424
|
+
it('autopilot production composition isolates concurrent session enablement, disablement, and safe snapshots', async () => {
|
|
425
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
426
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
427
|
+
ownTemporaryPaths(root, dataDir);
|
|
428
|
+
const workspacePath = join(root, 'workspace');
|
|
429
|
+
await mkdir(workspacePath);
|
|
430
|
+
const handles = [];
|
|
431
|
+
const app = await composeAuthorizedApp({
|
|
432
|
+
root,
|
|
433
|
+
dataDir,
|
|
434
|
+
relyingParty,
|
|
435
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
436
|
+
startAppServers: true,
|
|
437
|
+
launchAppServer: liveAppServer(handles),
|
|
438
|
+
profiles: {
|
|
439
|
+
list: async () => [],
|
|
440
|
+
require: async () => ({
|
|
441
|
+
name: 'default',
|
|
442
|
+
state: 'ok',
|
|
443
|
+
status: 'ready',
|
|
444
|
+
}),
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
const sessions = await Promise.all([createComposedSession(app), createComposedSession(app)]);
|
|
448
|
+
await vi.waitFor(async () => expect(await Promise.all(sessions.map(async (sessionId) => (await app.inject(`/api/sessions/${sessionId}`)).json().state))).toEqual(['ready', 'ready']));
|
|
449
|
+
const planText = `#+TITLE: Autopilot fixture
|
|
450
|
+
* WIP [#A] Parent
|
|
451
|
+
:PROPERTIES:
|
|
452
|
+
:ID: parent
|
|
453
|
+
:SKILLS: $gestalt:org-plan
|
|
454
|
+
:REVIEW_STATUS: UNREVIEWED
|
|
455
|
+
:END:
|
|
456
|
+
- Effort :: Small
|
|
457
|
+
- Goal :: Keep moving.
|
|
458
|
+
- Notes :: Coordinator fixture.
|
|
459
|
+
** TODO [#A] Child
|
|
460
|
+
:PROPERTIES:
|
|
461
|
+
:ID: child
|
|
462
|
+
:END:
|
|
463
|
+
- Why :: Exercise the coordinator.
|
|
464
|
+
- Change :: Publish a safe state.
|
|
465
|
+
- Tests :: Exercise production routes.
|
|
466
|
+
- Done when :: The plan remains incomplete.
|
|
467
|
+
`;
|
|
468
|
+
await Promise.all(sessions.map(async (sessionId, index) => {
|
|
469
|
+
const planPath = join(workspacePath, `autopilot-${index}.org`);
|
|
470
|
+
await writeFile(planPath, planText);
|
|
471
|
+
await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), planPath), JSON.stringify({
|
|
472
|
+
schemaVersion: 1,
|
|
473
|
+
planPath,
|
|
474
|
+
reason: 'supervision-start',
|
|
475
|
+
updatedAt: new Date().toISOString(),
|
|
476
|
+
}));
|
|
477
|
+
await expect
|
|
478
|
+
.poll(async () => (await app.inject(`/api/sessions/${sessionId}/plan`)).statusCode)
|
|
479
|
+
.toBe(200);
|
|
480
|
+
expect((await app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/restore` }))
|
|
481
|
+
.statusCode).toBe(200);
|
|
482
|
+
}));
|
|
483
|
+
await vi.waitFor(async () => expect(await Promise.all(sessions.map(async (sessionId) => (await app.inject(`/api/sessions/${sessionId}`)).json().threadId))).toEqual([expect.any(String), expect.any(String)]));
|
|
484
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
485
|
+
for (const sessionId of sessions) {
|
|
486
|
+
expect((await app.inject({
|
|
487
|
+
method: 'PUT',
|
|
488
|
+
url: `/api/sessions/${sessionId}/autopilot`,
|
|
489
|
+
payload: { enabled: true },
|
|
490
|
+
})).statusCode).toBe(200);
|
|
491
|
+
}
|
|
492
|
+
const [first, second] = sessions;
|
|
493
|
+
expect((await app.inject({
|
|
494
|
+
method: 'PUT',
|
|
495
|
+
url: `/api/sessions/${first}/autopilot`,
|
|
496
|
+
payload: { enabled: false },
|
|
497
|
+
})).json()).toMatchObject({
|
|
498
|
+
autopilot: { enabled: false, state: 'disabled' },
|
|
499
|
+
});
|
|
500
|
+
expect((await app.inject(`/api/sessions/${second}`)).json()).toMatchObject({
|
|
501
|
+
autopilot: { enabled: true },
|
|
502
|
+
});
|
|
503
|
+
expect(JSON.stringify(await app.inject('/api/sessions'))).not.toContain('Inspect the active supervised Org Plan');
|
|
504
|
+
await app.close();
|
|
505
|
+
});
|
|
506
|
+
it('production same-DB restart rearms future backoff and claims overdue once', async () => {
|
|
507
|
+
const timers = [];
|
|
508
|
+
let coordinator;
|
|
509
|
+
const fixture = await createProductionAutopilotFixture({
|
|
510
|
+
autopilotSchedule: (callback, delayMs) => {
|
|
511
|
+
timers.push({ callback, delayMs });
|
|
512
|
+
return () => undefined;
|
|
513
|
+
},
|
|
514
|
+
onAutopilotCoordinator: (value) => {
|
|
515
|
+
coordinator = value;
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
expect((await fixture.app.inject({
|
|
519
|
+
method: 'PUT',
|
|
520
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
521
|
+
payload: { enabled: true },
|
|
522
|
+
})).statusCode).toBe(200);
|
|
523
|
+
await vi.waitFor(() => expect(timers.length).toBeGreaterThan(0));
|
|
524
|
+
coordinator.dispose(fixture.sessionId);
|
|
525
|
+
timers.length = 0;
|
|
526
|
+
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
527
|
+
const future = new Date(Date.now() + 60_000).toISOString();
|
|
528
|
+
const timestamp = new Date().toISOString();
|
|
529
|
+
database
|
|
530
|
+
.prepare("UPDATE autopilot_sessions SET state = 'backoff', generation = 7, no_progress_count = 0, next_evaluation_at = ?, last_control_id = 'future-control', stop_reason = NULL, updated_at = ? WHERE session_id = ?")
|
|
531
|
+
.run(future, timestamp, fixture.sessionId);
|
|
532
|
+
database
|
|
533
|
+
.prepare('DELETE FROM autopilot_controls WHERE session_id = ?')
|
|
534
|
+
.run(fixture.sessionId);
|
|
535
|
+
database
|
|
536
|
+
.prepare("INSERT INTO autopilot_controls (session_id,control_id,status,created_at,updated_at,failure_code,turn_id) VALUES (?, 'future-control', 'scheduled', ?, ?, NULL, NULL)")
|
|
537
|
+
.run(fixture.sessionId, timestamp, timestamp);
|
|
538
|
+
database.close();
|
|
539
|
+
coordinator.restore(fixture.sessionId);
|
|
540
|
+
expect(timers).toHaveLength(1);
|
|
541
|
+
expect(timers[0].delayMs).toBeGreaterThan(0);
|
|
542
|
+
// A fresh process sees the same durable state after its wall-clock deadline.
|
|
543
|
+
timers[0].callback();
|
|
544
|
+
timers[0].callback();
|
|
545
|
+
await vi.waitFor(() => expect(fixture.handles.flatMap((handle) => handle.calls).filter((call) => call === 'turn/start')).toHaveLength(1));
|
|
546
|
+
const audit = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
547
|
+
const issued = audit
|
|
548
|
+
.prepare("SELECT count(*) AS count FROM autopilot_controls WHERE session_id = ? AND status = 'started'")
|
|
549
|
+
.get(fixture.sessionId);
|
|
550
|
+
const events = audit
|
|
551
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.turn-started'")
|
|
552
|
+
.get(fixture.sessionId);
|
|
553
|
+
audit.close();
|
|
554
|
+
expect(issued.count).toBe(1);
|
|
555
|
+
expect(events.count).toBe(1);
|
|
556
|
+
await fixture.app.close();
|
|
557
|
+
});
|
|
558
|
+
it('production restart unexplained issued requires attention while issued persisted activeTurn records one started audit without replay', async () => {
|
|
559
|
+
let coordinator;
|
|
560
|
+
const fixture = await createProductionAutopilotFixture({
|
|
561
|
+
onAutopilotCoordinator: (value) => {
|
|
562
|
+
coordinator = value;
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
566
|
+
const timestamp = new Date().toISOString();
|
|
567
|
+
database
|
|
568
|
+
.prepare("INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-without-turn', NULL, ?)")
|
|
569
|
+
.run(fixture.sessionId, timestamp, timestamp);
|
|
570
|
+
database
|
|
571
|
+
.prepare("INSERT INTO autopilot_controls (session_id,control_id,status,created_at,updated_at,failure_code,turn_id) VALUES (?, 'issued-without-turn', 'issued', ?, ?, NULL, NULL)")
|
|
572
|
+
.run(fixture.sessionId, timestamp, timestamp);
|
|
573
|
+
database.close();
|
|
574
|
+
coordinator.restore(fixture.sessionId);
|
|
575
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
576
|
+
autopilot: { enabled: false, state: 'attentionRequired', reason: 'reconcileFailed' },
|
|
577
|
+
}));
|
|
578
|
+
expect(fixture.handles.flatMap((handle) => handle.calls)).not.toContain('turn/start');
|
|
579
|
+
// This coordinator safety stop has no tool request id. Recovery is the
|
|
580
|
+
// ordinary durable Autopilot toggle, not the attention resolver route.
|
|
581
|
+
expect((await fixture.app.inject({
|
|
582
|
+
method: 'PUT',
|
|
583
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
584
|
+
payload: { enabled: true },
|
|
585
|
+
})).statusCode).toBe(200);
|
|
586
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
587
|
+
autopilot: { enabled: true, state: expect.stringMatching(/monitoring|backoff/) },
|
|
588
|
+
}));
|
|
589
|
+
await fixture.app.close();
|
|
590
|
+
let recovered;
|
|
591
|
+
const accepted = await createProductionAutopilotFixture({
|
|
592
|
+
onAutopilotCoordinator: (value) => {
|
|
593
|
+
recovered = value;
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
const acceptedDatabase = new DatabaseSync(join(accepted.dataDir, 'relay.sqlite'));
|
|
597
|
+
acceptedDatabase
|
|
598
|
+
.prepare("INSERT INTO autopilot_sessions (session_id,state,requested_enabled,plan_identity,plan_fingerprint,generation,no_progress_count,next_evaluation_at,last_control_id,stop_reason,updated_at) VALUES (?, 'backoff', 1, 'fixture', 'fixture', 1, 0, ?, 'issued-with-turn', NULL, ?)")
|
|
599
|
+
.run(accepted.sessionId, timestamp, timestamp);
|
|
600
|
+
acceptedDatabase
|
|
601
|
+
.prepare("INSERT INTO autopilot_controls (session_id,control_id,status,created_at,updated_at,failure_code,turn_id) VALUES (?, 'issued-with-turn', 'issued', ?, ?, NULL, NULL)")
|
|
602
|
+
.run(accepted.sessionId, timestamp, timestamp);
|
|
603
|
+
acceptedDatabase
|
|
604
|
+
.prepare("UPDATE relay_sessions SET active_turn_id = 'persisted-turn' WHERE id = ?")
|
|
605
|
+
.run(accepted.sessionId);
|
|
606
|
+
acceptedDatabase.close();
|
|
607
|
+
recovered.restore(accepted.sessionId);
|
|
608
|
+
await vi.waitFor(async () => expect((await accepted.app.inject(`/api/sessions/${accepted.sessionId}`)).json()).toMatchObject({
|
|
609
|
+
autopilot: { enabled: true, state: 'backoff' },
|
|
610
|
+
}));
|
|
611
|
+
const auditDatabase = new DatabaseSync(join(accepted.dataDir, 'relay.sqlite'));
|
|
612
|
+
const started = auditDatabase
|
|
613
|
+
.prepare("SELECT count(*) AS count FROM autopilot_controls WHERE session_id = ? AND status = 'started'")
|
|
614
|
+
.get(accepted.sessionId);
|
|
615
|
+
const audits = auditDatabase
|
|
616
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.turn-started'")
|
|
617
|
+
.get(accepted.sessionId);
|
|
618
|
+
auditDatabase.close();
|
|
619
|
+
expect(started.count).toBe(1);
|
|
620
|
+
expect(audits.count).toBe(1);
|
|
621
|
+
recovered.restore(accepted.sessionId);
|
|
622
|
+
expect(accepted.handles.flatMap((handle) => handle.calls)).not.toContain('turn/start');
|
|
623
|
+
await accepted.app.close();
|
|
624
|
+
});
|
|
625
|
+
it('production attention request requires explicit re-enable and a complete plan transitions to completed', async () => {
|
|
626
|
+
const fixture = await createProductionAutopilotFixture();
|
|
627
|
+
const handle = fixture.handles.find((candidate) => candidate.request);
|
|
628
|
+
expect((await fixture.app.inject({
|
|
629
|
+
method: 'PUT',
|
|
630
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
631
|
+
payload: { enabled: true },
|
|
632
|
+
})).statusCode).toBe(200);
|
|
633
|
+
const attention = handle.request(attentionCall(810));
|
|
634
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
635
|
+
autopilot: { state: 'attentionRequired', enabled: false, reason: 'attentionRequired' },
|
|
636
|
+
}));
|
|
637
|
+
expect(fixture.handles.flatMap((candidate) => candidate.calls)).not.toContain('turn/start');
|
|
638
|
+
expect((await fixture.app.inject({
|
|
639
|
+
method: 'POST',
|
|
640
|
+
url: `/api/sessions/${fixture.sessionId}/attention/810/resolve`,
|
|
641
|
+
payload: { operationKey: 'resume-attention', action: 'resume' },
|
|
642
|
+
})).statusCode).toBe(202);
|
|
643
|
+
await expect(attention).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
|
|
644
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
645
|
+
expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
646
|
+
autopilot: { state: 'attentionRequired', enabled: false },
|
|
647
|
+
});
|
|
648
|
+
expect((await fixture.app.inject({
|
|
649
|
+
method: 'PUT',
|
|
650
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
651
|
+
payload: { enabled: true },
|
|
652
|
+
})).statusCode).toBe(200);
|
|
653
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
654
|
+
autopilot: { enabled: true, state: expect.stringMatching(/monitoring|backoff/) },
|
|
655
|
+
}));
|
|
656
|
+
await writeFile(join(fixture.workspacePath, 'autopilot.org'), completedAutopilotPlanText());
|
|
657
|
+
await expect
|
|
658
|
+
.poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}/plan`)).json()
|
|
659
|
+
.executionComplete)
|
|
660
|
+
.toBe(true);
|
|
661
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
662
|
+
autopilot: { state: 'completed', enabled: false, reason: 'planComplete' },
|
|
663
|
+
}));
|
|
664
|
+
await fixture.app.close();
|
|
665
|
+
});
|
|
666
|
+
it('production plan removal, replacement, and session termination cancel queued continuations', async () => {
|
|
667
|
+
for (const action of ['close', 'replace', 'stop', 'release', 'delete']) {
|
|
668
|
+
const timers = [];
|
|
669
|
+
const fixture = await createProductionAutopilotFixture({
|
|
670
|
+
autopilotSchedule: (callback) => {
|
|
671
|
+
const timer = { callback, cancelled: false };
|
|
672
|
+
timers.push(timer);
|
|
673
|
+
return () => {
|
|
674
|
+
timer.cancelled = true;
|
|
675
|
+
};
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
expect((await fixture.app.inject({
|
|
679
|
+
method: 'PUT',
|
|
680
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
681
|
+
payload: { enabled: true },
|
|
682
|
+
})).statusCode).toBe(200);
|
|
683
|
+
await vi.waitFor(() => expect(timers).toHaveLength(1));
|
|
684
|
+
const staleTimer = timers[0];
|
|
685
|
+
if (action === 'close') {
|
|
686
|
+
await writeFile(join(fixture.workspacePath, 'autopilot.org'), completedAutopilotPlanText());
|
|
687
|
+
await expect
|
|
688
|
+
.poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}/plan`)).json()
|
|
689
|
+
.allDone)
|
|
690
|
+
.toBe(true);
|
|
691
|
+
expect((await fixture.app.inject({
|
|
692
|
+
method: 'DELETE',
|
|
693
|
+
url: `/api/sessions/${fixture.sessionId}/plan`,
|
|
694
|
+
})).statusCode).toBe(204);
|
|
695
|
+
}
|
|
696
|
+
else if (action === 'replace') {
|
|
697
|
+
await installAutopilotPlan(fixture.app, fixture.sessionId, fixture.workspacePath, 'replacement');
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
const method = action === 'delete' ? 'DELETE' : 'POST';
|
|
701
|
+
const suffix = action === 'delete' ? '' : `/${action}`;
|
|
702
|
+
expect((await fixture.app.inject({
|
|
703
|
+
method,
|
|
704
|
+
url: `/api/sessions/${fixture.sessionId}${suffix}`,
|
|
705
|
+
})).statusCode).toBeGreaterThanOrEqual(200);
|
|
706
|
+
}
|
|
707
|
+
if (action === 'delete') {
|
|
708
|
+
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
709
|
+
const row = database
|
|
710
|
+
.prepare('SELECT state, requested_enabled, stop_reason FROM autopilot_sessions WHERE session_id = ?')
|
|
711
|
+
.get(fixture.sessionId);
|
|
712
|
+
database.close();
|
|
713
|
+
// Forgetting cascades the terminal row with the session itself; the
|
|
714
|
+
// absence is the durable proof that no queued control can be reopened.
|
|
715
|
+
expect(row).toBeUndefined();
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
719
|
+
autopilot: {
|
|
720
|
+
enabled: false,
|
|
721
|
+
state: 'disabled',
|
|
722
|
+
reason: action === 'replace'
|
|
723
|
+
? 'planReplaced'
|
|
724
|
+
: action === 'close'
|
|
725
|
+
? 'planRemoved'
|
|
726
|
+
: 'sessionEnded',
|
|
727
|
+
},
|
|
728
|
+
}));
|
|
729
|
+
}
|
|
730
|
+
expect(staleTimer.cancelled).toBe(true);
|
|
731
|
+
staleTimer.callback();
|
|
732
|
+
await Promise.resolve();
|
|
733
|
+
expect(fixture.handles.flatMap((candidate) => candidate.calls)).not.toContain('turn/start');
|
|
734
|
+
await fixture.app.close();
|
|
735
|
+
}
|
|
736
|
+
}, 15_000);
|
|
737
|
+
it('production duplicate and missing completion notifications coalesce without double control', async () => {
|
|
738
|
+
const timers = [];
|
|
739
|
+
const fixture = await createProductionAutopilotFixture({
|
|
740
|
+
autopilotSchedule: (callback) => {
|
|
741
|
+
const timer = { callback, cancelled: false, fired: false };
|
|
742
|
+
timers.push(timer);
|
|
743
|
+
return () => {
|
|
744
|
+
timer.cancelled = true;
|
|
745
|
+
};
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
const runNextTimer = async () => {
|
|
749
|
+
let timer;
|
|
750
|
+
await vi.waitFor(() => {
|
|
751
|
+
timer = timers.find((candidate) => !candidate.cancelled && !candidate.fired);
|
|
752
|
+
expect(timer).toBeDefined();
|
|
753
|
+
});
|
|
754
|
+
timer.fired = true;
|
|
755
|
+
timer.callback();
|
|
756
|
+
};
|
|
757
|
+
const response = await fixture.app.inject({
|
|
758
|
+
method: 'PUT',
|
|
759
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
760
|
+
payload: { enabled: true },
|
|
761
|
+
});
|
|
762
|
+
expect(response.statusCode).toBe(200);
|
|
763
|
+
await runNextTimer();
|
|
764
|
+
await vi.waitFor(() => expect(fixture.handles
|
|
765
|
+
.flatMap((handle) => handle.calls)
|
|
766
|
+
.filter((call) => call === 'turn/start')).toHaveLength(1), { timeout: 2_500 });
|
|
767
|
+
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
768
|
+
const active = database
|
|
769
|
+
.prepare('SELECT active_turn_id FROM relay_sessions WHERE id = ?')
|
|
770
|
+
.get(fixture.sessionId);
|
|
771
|
+
database.close();
|
|
772
|
+
const handle = fixture.handles.find((candidate) => candidate.notify);
|
|
773
|
+
expect(handle?.notify).toBeDefined();
|
|
774
|
+
const completion = {
|
|
775
|
+
method: 'turn/completed',
|
|
776
|
+
params: { turn: { id: active.active_turn_id } },
|
|
777
|
+
};
|
|
778
|
+
handle.notify(completion);
|
|
779
|
+
handle.notify(completion);
|
|
780
|
+
await vi.waitFor(() => expect(timers.filter((timer) => !timer.cancelled && !timer.fired)).toHaveLength(1));
|
|
781
|
+
await runNextTimer();
|
|
782
|
+
await runNextTimer();
|
|
783
|
+
await vi.waitFor(() => expect(fixture.handles
|
|
784
|
+
.flatMap((candidate) => candidate.calls)
|
|
785
|
+
.filter((call) => call === 'turn/start')).toHaveLength(2), { timeout: 3_500 });
|
|
786
|
+
const controlsDatabase = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
787
|
+
const controls = controlsDatabase
|
|
788
|
+
.prepare('SELECT control_id FROM autopilot_controls WHERE session_id = ?')
|
|
789
|
+
.all(fixture.sessionId);
|
|
790
|
+
controlsDatabase.close();
|
|
791
|
+
expect(new Set(controls.map((control) => control.control_id)).size).toBe(controls.length);
|
|
792
|
+
expect(controls).toHaveLength(2);
|
|
793
|
+
await fixture.app.close();
|
|
794
|
+
});
|
|
795
|
+
it('production child-idle transition wakes an idle supervised autopilot', async () => {
|
|
796
|
+
const timers = [];
|
|
797
|
+
const fixture = await createProductionAutopilotFixture({
|
|
798
|
+
autopilotSchedule: (callback) => {
|
|
799
|
+
const timer = { callback, cancelled: false, fired: false };
|
|
800
|
+
timers.push(timer);
|
|
801
|
+
return () => {
|
|
802
|
+
timer.cancelled = true;
|
|
803
|
+
};
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
const handle = fixture.handles.find((candidate) => candidate.notify);
|
|
807
|
+
handle.notify({
|
|
808
|
+
method: 'thread/started',
|
|
809
|
+
params: { thread: { id: 'thread-1', status: { type: 'active' } } },
|
|
810
|
+
});
|
|
811
|
+
handle.notify({
|
|
812
|
+
method: 'item/started',
|
|
813
|
+
params: {
|
|
814
|
+
item: {
|
|
815
|
+
type: 'collabToolCall',
|
|
816
|
+
tool: 'spawn_agent',
|
|
817
|
+
status: 'inProgress',
|
|
818
|
+
senderThreadId: 'thread-1',
|
|
819
|
+
receiverThreadId: 'child-1',
|
|
820
|
+
agentStatus: 'working',
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
handle.notify({
|
|
825
|
+
method: 'thread/status/changed',
|
|
826
|
+
params: { threadId: 'thread-1', status: { type: 'idle' } },
|
|
827
|
+
});
|
|
828
|
+
expect((await fixture.app.inject({
|
|
829
|
+
method: 'PUT',
|
|
830
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
831
|
+
payload: { enabled: true },
|
|
832
|
+
})).statusCode).toBe(200);
|
|
833
|
+
expect(timers).toHaveLength(0);
|
|
834
|
+
handle.notify({
|
|
835
|
+
method: 'item/completed',
|
|
836
|
+
params: {
|
|
837
|
+
item: {
|
|
838
|
+
type: 'collabToolCall',
|
|
839
|
+
tool: 'wait',
|
|
840
|
+
status: 'completed',
|
|
841
|
+
senderThreadId: 'thread-1',
|
|
842
|
+
receiverThreadId: 'child-1',
|
|
843
|
+
agentStatus: 'idle',
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
expect(timers.filter((timer) => !timer.cancelled && !timer.fired)).toHaveLength(1);
|
|
848
|
+
for (let index = 0; index < 2; index += 1) {
|
|
849
|
+
const timer = timers.find((candidate) => !candidate.cancelled && !candidate.fired);
|
|
850
|
+
expect(timer).toBeDefined();
|
|
851
|
+
timer.fired = true;
|
|
852
|
+
timer.callback();
|
|
853
|
+
}
|
|
854
|
+
await vi.waitFor(() => expect(fixture.handles
|
|
855
|
+
.flatMap((candidate) => candidate.calls)
|
|
856
|
+
.filter((call) => call === 'turn/start')).toHaveLength(1));
|
|
857
|
+
await fixture.app.close();
|
|
858
|
+
});
|
|
859
|
+
it('production blocked child wakes the idle supervisor for bounded recovery', async () => {
|
|
860
|
+
const timers = [];
|
|
861
|
+
const fixture = await createProductionAutopilotFixture({
|
|
862
|
+
autopilotSchedule: (callback) => {
|
|
863
|
+
const timer = { callback, cancelled: false, fired: false };
|
|
864
|
+
timers.push(timer);
|
|
865
|
+
return () => {
|
|
866
|
+
timer.cancelled = true;
|
|
867
|
+
};
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
const handle = fixture.handles.find((candidate) => candidate.notify);
|
|
871
|
+
handle.notify({
|
|
872
|
+
method: 'thread/started',
|
|
873
|
+
params: { thread: { id: 'thread-1', status: { type: 'idle' } } },
|
|
874
|
+
});
|
|
875
|
+
handle.notify({
|
|
876
|
+
method: 'item/started',
|
|
877
|
+
params: {
|
|
878
|
+
item: {
|
|
879
|
+
type: 'collabToolCall',
|
|
880
|
+
tool: 'spawn_agent',
|
|
881
|
+
status: 'inProgress',
|
|
882
|
+
senderThreadId: 'thread-1',
|
|
883
|
+
receiverThreadId: 'child-1',
|
|
884
|
+
agentStatus: 'working',
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
});
|
|
888
|
+
expect((await fixture.app.inject({
|
|
889
|
+
method: 'PUT',
|
|
890
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
891
|
+
payload: { enabled: true },
|
|
892
|
+
})).statusCode).toBe(200);
|
|
893
|
+
expect(timers).toHaveLength(0);
|
|
894
|
+
handle.notify({
|
|
895
|
+
method: 'item/completed',
|
|
896
|
+
params: {
|
|
897
|
+
item: {
|
|
898
|
+
type: 'collabToolCall',
|
|
899
|
+
tool: 'wait',
|
|
900
|
+
status: 'failed',
|
|
901
|
+
senderThreadId: 'thread-1',
|
|
902
|
+
receiverThreadId: 'child-1',
|
|
903
|
+
agentStatus: 'failed',
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
const quietTimer = timers.find((timer) => !timer.cancelled && !timer.fired);
|
|
908
|
+
expect(quietTimer).toBeDefined();
|
|
909
|
+
quietTimer.fired = true;
|
|
910
|
+
quietTimer.callback();
|
|
911
|
+
const continuationTimer = timers.find((timer) => !timer.cancelled && !timer.fired);
|
|
912
|
+
expect(continuationTimer).toBeDefined();
|
|
913
|
+
continuationTimer.fired = true;
|
|
914
|
+
continuationTimer.callback();
|
|
915
|
+
await vi.waitFor(() => expect(fixture.handles
|
|
916
|
+
.flatMap((candidate) => candidate.calls)
|
|
917
|
+
.filter((call) => call === 'turn/start')).toHaveLength(1));
|
|
918
|
+
await fixture.app.close();
|
|
919
|
+
});
|
|
920
|
+
it('production incompatible exhausted reconcile becomes typed attention with no later reads or timers', async () => {
|
|
921
|
+
let reconciliations = 0;
|
|
922
|
+
const timers = [];
|
|
923
|
+
let coordinator;
|
|
924
|
+
const fixture = await createProductionAutopilotFixture({
|
|
925
|
+
autopilotActivity: () => null,
|
|
926
|
+
autopilotReconcile: async () => {
|
|
927
|
+
reconciliations += 1;
|
|
928
|
+
return { compatible: false };
|
|
929
|
+
},
|
|
930
|
+
autopilotSchedule: (callback) => {
|
|
931
|
+
timers.push(callback);
|
|
932
|
+
return () => undefined;
|
|
933
|
+
},
|
|
934
|
+
onAutopilotCoordinator: (value) => {
|
|
935
|
+
coordinator = value;
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
expect((await fixture.app.inject({
|
|
939
|
+
method: 'PUT',
|
|
940
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
941
|
+
payload: { enabled: true },
|
|
942
|
+
})).statusCode).toBe(200);
|
|
943
|
+
await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
|
|
944
|
+
autopilot: { state: 'attentionRequired', enabled: false, reason: 'reconcileFailed' },
|
|
945
|
+
}));
|
|
946
|
+
const stableReconciliations = reconciliations;
|
|
947
|
+
const stableTimers = timers.length;
|
|
948
|
+
coordinator.evaluate(fixture.sessionId);
|
|
949
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
950
|
+
expect(reconciliations).toBe(stableReconciliations);
|
|
951
|
+
expect(timers).toHaveLength(stableTimers);
|
|
952
|
+
await fixture.app.close();
|
|
953
|
+
});
|
|
954
|
+
it('production post-acceptance fault recovers one accepted control without a second turn or audit', async () => {
|
|
955
|
+
let coordinator;
|
|
956
|
+
const fixture = await createProductionAutopilotFixture({
|
|
957
|
+
autopilotAfterTurnAccepted: () => {
|
|
958
|
+
throw new Error('fault after durable app-server acceptance');
|
|
959
|
+
},
|
|
960
|
+
onAutopilotCoordinator: (value) => {
|
|
961
|
+
coordinator = value;
|
|
962
|
+
},
|
|
963
|
+
});
|
|
964
|
+
const response = await fixture.app.inject({
|
|
965
|
+
method: 'PUT',
|
|
966
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
967
|
+
payload: { enabled: true },
|
|
968
|
+
});
|
|
969
|
+
expect(response.statusCode).toBe(200);
|
|
970
|
+
await vi.waitFor(() => expect(fixture.handles
|
|
971
|
+
.flatMap((handle) => handle.calls)
|
|
972
|
+
.filter((call) => call === 'turn/start')).toHaveLength(1), { timeout: 2_500 });
|
|
973
|
+
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
974
|
+
const control = database
|
|
975
|
+
.prepare("SELECT status, turn_id FROM autopilot_controls WHERE session_id = ? AND status = 'started'")
|
|
976
|
+
.get(fixture.sessionId);
|
|
977
|
+
const startedAudits = database
|
|
978
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.turn-started'")
|
|
979
|
+
.get(fixture.sessionId);
|
|
980
|
+
const failedAudits = database
|
|
981
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.turn-failed'")
|
|
982
|
+
.get(fixture.sessionId);
|
|
983
|
+
database.close();
|
|
984
|
+
expect(control).toMatchObject({ status: 'started', turn_id: expect.any(String) });
|
|
985
|
+
expect(startedAudits.count).toBe(1);
|
|
986
|
+
expect(failedAudits.count).toBe(0);
|
|
987
|
+
// Rehydrate from the same durable store after the post-acceptance fault;
|
|
988
|
+
// the active turn fences replay and the outbox preserves one audit identity.
|
|
989
|
+
coordinator.restore(fixture.sessionId);
|
|
990
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
991
|
+
expect(fixture.handles.flatMap((handle) => handle.calls).filter((call) => call === 'turn/start')).toHaveLength(1);
|
|
992
|
+
const reopened = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
993
|
+
expect(reopened
|
|
994
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.turn-started'")
|
|
995
|
+
.get(fixture.sessionId)).toMatchObject({ count: 1 });
|
|
996
|
+
reopened.close();
|
|
997
|
+
await fixture.app.close();
|
|
998
|
+
});
|
|
999
|
+
it('production pre-acceptance runtime failure durably records one failed control and does not create a started audit', async () => {
|
|
1000
|
+
const fixture = await createProductionAutopilotFixture({
|
|
1001
|
+
autopilotBeforeTurnAccepted: () => {
|
|
1002
|
+
throw new Error('temporary runtime transport failure');
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
expect((await fixture.app.inject({
|
|
1006
|
+
method: 'PUT',
|
|
1007
|
+
url: `/api/sessions/${fixture.sessionId}/autopilot`,
|
|
1008
|
+
payload: { enabled: true },
|
|
1009
|
+
})).statusCode).toBe(200);
|
|
1010
|
+
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
|
1011
|
+
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
1012
|
+
const failed = database
|
|
1013
|
+
.prepare("SELECT count(*) AS count FROM autopilot_controls WHERE session_id = ? AND status = 'failed'")
|
|
1014
|
+
.get(fixture.sessionId);
|
|
1015
|
+
const started = database
|
|
1016
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.turn-started'")
|
|
1017
|
+
.get(fixture.sessionId);
|
|
1018
|
+
const failedAudits = database
|
|
1019
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.turn-failed'")
|
|
1020
|
+
.get(fixture.sessionId);
|
|
1021
|
+
database.close();
|
|
1022
|
+
expect(failed.count).toBe(1);
|
|
1023
|
+
expect(failedAudits.count).toBe(1);
|
|
1024
|
+
expect(started.count).toBe(0);
|
|
1025
|
+
await fixture.app.close();
|
|
1026
|
+
});
|
|
1027
|
+
});
|
|
1028
|
+
describeCompositionConcern('attention', () => {
|
|
1029
|
+
it('publishes isolated typed required, resolved, and failed attention transitions through the feature-only seam', async () => {
|
|
1030
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1031
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1032
|
+
ownTemporaryPaths(root, dataDir);
|
|
1033
|
+
await mkdir(join(root, 'workspace'));
|
|
1034
|
+
const handles = [];
|
|
1035
|
+
let transitions;
|
|
1036
|
+
const app = await composeAuthorizedApp({
|
|
1037
|
+
root,
|
|
1038
|
+
dataDir,
|
|
1039
|
+
relyingParty,
|
|
1040
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1041
|
+
startAppServers: true,
|
|
1042
|
+
launchAppServer: liveAppServer(handles),
|
|
1043
|
+
profiles: {
|
|
1044
|
+
list: async () => [],
|
|
1045
|
+
require: async () => ({
|
|
1046
|
+
name: 'default',
|
|
1047
|
+
state: 'ok',
|
|
1048
|
+
status: 'ready',
|
|
1049
|
+
}),
|
|
1050
|
+
},
|
|
1051
|
+
onAttentionTransitions: (port) => {
|
|
1052
|
+
transitions = port;
|
|
1053
|
+
},
|
|
1054
|
+
});
|
|
1055
|
+
expect(transitions).toBeDefined();
|
|
1056
|
+
const sessionA = await createComposedSession(app);
|
|
1057
|
+
const sessionB = await createComposedSession(app);
|
|
1058
|
+
const handleA = await vi.waitFor(() => {
|
|
1059
|
+
const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
|
|
1060
|
+
expect(handle?.request).toBeDefined();
|
|
1061
|
+
return handle;
|
|
1062
|
+
});
|
|
1063
|
+
const handleB = await vi.waitFor(() => {
|
|
1064
|
+
const started = handles.filter((candidate) => candidate.calls.includes('thread/start'));
|
|
1065
|
+
expect(started).toHaveLength(2);
|
|
1066
|
+
expect(started[1]?.request).toBeDefined();
|
|
1067
|
+
return started[1];
|
|
1068
|
+
});
|
|
1069
|
+
const receivedA = [];
|
|
1070
|
+
const receivedB = [];
|
|
1071
|
+
const unsubscribeA = transitions.subscribe(sessionA, (event) => receivedA.push(event));
|
|
1072
|
+
const unsubscribeB = transitions.subscribe(sessionB, (event) => receivedB.push(event));
|
|
1073
|
+
const resolving = handleA.request(attentionCall(701));
|
|
1074
|
+
await vi.waitFor(() => expect(receivedA).toEqual([
|
|
1075
|
+
expect.objectContaining({ kind: 'required', requestId: '701' }),
|
|
1076
|
+
]));
|
|
1077
|
+
expect(receivedB).toEqual([]);
|
|
1078
|
+
expect((await app.inject({
|
|
1079
|
+
method: 'POST',
|
|
1080
|
+
url: `/api/sessions/${sessionA}/attention/701/resolve`,
|
|
1081
|
+
payload: { operationKey: 'resolve-a', action: 'resume' },
|
|
1082
|
+
})).statusCode).toBe(202);
|
|
1083
|
+
await expect(resolving).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
|
|
1084
|
+
await vi.waitFor(() => expect(receivedA).toEqual([
|
|
1085
|
+
expect.objectContaining({ kind: 'required', requestId: '701' }),
|
|
1086
|
+
expect.objectContaining({ kind: 'resolved', requestId: '701' }),
|
|
1087
|
+
]));
|
|
1088
|
+
const failing = handleB.request(attentionCall(702, 'permissionRequired'));
|
|
1089
|
+
await vi.waitFor(() => expect(receivedB).toEqual([
|
|
1090
|
+
expect.objectContaining({ kind: 'required', requestId: '702' }),
|
|
1091
|
+
]));
|
|
1092
|
+
handleB.notify({
|
|
1093
|
+
method: 'serverRequest/resolved',
|
|
1094
|
+
params: { threadId: 'thread-ignored', requestId: 702 },
|
|
1095
|
+
});
|
|
1096
|
+
await expect(failing).rejects.toMatchObject({ message: 'CODEX_SERVER_REQUEST_CLEARED' });
|
|
1097
|
+
await vi.waitFor(() => expect(receivedB).toEqual([
|
|
1098
|
+
expect.objectContaining({ kind: 'required', requestId: '702' }),
|
|
1099
|
+
expect.objectContaining({ kind: 'failed', requestId: '702' }),
|
|
1100
|
+
]));
|
|
1101
|
+
unsubscribeA();
|
|
1102
|
+
const afterUnsubscribe = handleA.request(attentionCall(703));
|
|
1103
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionA}/attention`)).json()).toMatchObject({
|
|
1104
|
+
requestId: '703',
|
|
1105
|
+
}));
|
|
1106
|
+
expect(receivedA).toHaveLength(2);
|
|
1107
|
+
handleA.notify({
|
|
1108
|
+
method: 'serverRequest/resolved',
|
|
1109
|
+
params: { threadId: 'thread-ignored', requestId: 703 },
|
|
1110
|
+
});
|
|
1111
|
+
await expect(afterUnsubscribe).rejects.toMatchObject({
|
|
1112
|
+
message: 'CODEX_SERVER_REQUEST_CLEARED',
|
|
1113
|
+
});
|
|
1114
|
+
expect(receivedA).toHaveLength(2);
|
|
1115
|
+
unsubscribeB();
|
|
1116
|
+
await app.close();
|
|
1117
|
+
});
|
|
1118
|
+
it('keeps the typed attention blocker authoritative across simultaneous interaction resolutions', async () => {
|
|
1119
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1120
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1121
|
+
ownTemporaryPaths(root, dataDir);
|
|
1122
|
+
await mkdir(join(root, 'workspace'));
|
|
1123
|
+
const handles = [];
|
|
1124
|
+
const app = await composeAuthorizedApp({
|
|
1125
|
+
root,
|
|
1126
|
+
dataDir,
|
|
1127
|
+
relyingParty,
|
|
1128
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1129
|
+
startAppServers: true,
|
|
1130
|
+
launchAppServer: liveAppServer(handles),
|
|
1131
|
+
profiles: {
|
|
1132
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1133
|
+
require: async () => ({
|
|
1134
|
+
name: 'default',
|
|
1135
|
+
state: 'ok',
|
|
1136
|
+
status: 'ready',
|
|
1137
|
+
}),
|
|
1138
|
+
},
|
|
1139
|
+
});
|
|
1140
|
+
const sessionId = await createComposedSession(app);
|
|
1141
|
+
await vi.waitFor(() => expect(handles.find((handle) => handle.request)?.request).toBeDefined());
|
|
1142
|
+
const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
|
|
1143
|
+
const attentionFirst = handle.request(attentionCall(711, 'permissionRequired'));
|
|
1144
|
+
const inputFirst = handle.request({
|
|
1145
|
+
id: 712,
|
|
1146
|
+
method: 'item/tool/requestUserInput',
|
|
1147
|
+
params: { questions: [] },
|
|
1148
|
+
});
|
|
1149
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
1150
|
+
agentActivity: { root: { state: 'awaitingHuman', reason: 'pendingInteraction' } },
|
|
1151
|
+
}));
|
|
1152
|
+
expect((await app.inject({
|
|
1153
|
+
method: 'POST',
|
|
1154
|
+
url: `/api/sessions/${sessionId}/interactions/712`,
|
|
1155
|
+
payload: { answers: {} },
|
|
1156
|
+
})).statusCode).toBe(202);
|
|
1157
|
+
await expect(inputFirst).resolves.toEqual({ answers: {} });
|
|
1158
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
1159
|
+
agentActivity: { root: { state: 'awaitingHuman', reason: 'permissionRequired' } },
|
|
1160
|
+
}));
|
|
1161
|
+
await app.inject({
|
|
1162
|
+
method: 'POST',
|
|
1163
|
+
url: `/api/sessions/${sessionId}/attention/711/resolve`,
|
|
1164
|
+
payload: { operationKey: 'attention-first', action: 'resume' },
|
|
1165
|
+
});
|
|
1166
|
+
await expect(attentionFirst).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
|
|
1167
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
1168
|
+
agentActivity: { root: { state: 'working' } },
|
|
1169
|
+
}));
|
|
1170
|
+
const attentionSecond = handle.request(attentionCall(713));
|
|
1171
|
+
const inputSecond = handle.request({
|
|
1172
|
+
id: 714,
|
|
1173
|
+
method: 'item/tool/requestUserInput',
|
|
1174
|
+
params: { questions: [] },
|
|
1175
|
+
});
|
|
1176
|
+
await app.inject({
|
|
1177
|
+
method: 'POST',
|
|
1178
|
+
url: `/api/sessions/${sessionId}/attention/713/resolve`,
|
|
1179
|
+
payload: { operationKey: 'attention-second', action: 'resume' },
|
|
1180
|
+
});
|
|
1181
|
+
await expect(attentionSecond).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
|
|
1182
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
1183
|
+
agentActivity: { root: { state: 'awaitingHuman', reason: 'pendingInteraction' } },
|
|
1184
|
+
}));
|
|
1185
|
+
await app.inject({
|
|
1186
|
+
method: 'POST',
|
|
1187
|
+
url: `/api/sessions/${sessionId}/interactions/714`,
|
|
1188
|
+
payload: { answers: {} },
|
|
1189
|
+
});
|
|
1190
|
+
await expect(inputSecond).resolves.toEqual({ answers: {} });
|
|
1191
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
1192
|
+
agentActivity: { root: { state: 'working' } },
|
|
1193
|
+
}));
|
|
1194
|
+
await app.close();
|
|
1195
|
+
});
|
|
1196
|
+
it('preserves active and terminal attention state through a same-database relay reopen', async () => {
|
|
1197
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1198
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1199
|
+
ownTemporaryPaths(root, dataDir);
|
|
1200
|
+
await mkdir(join(root, 'workspace'));
|
|
1201
|
+
const handles = [];
|
|
1202
|
+
const profiles = {
|
|
1203
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1204
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1205
|
+
};
|
|
1206
|
+
const first = await composeAuthorizedApp({
|
|
1207
|
+
root,
|
|
1208
|
+
dataDir,
|
|
1209
|
+
relyingParty,
|
|
1210
|
+
profiles,
|
|
1211
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1212
|
+
startAppServers: true,
|
|
1213
|
+
launchAppServer: liveAppServer(handles),
|
|
1214
|
+
});
|
|
1215
|
+
const sessionId = await createComposedSession(first);
|
|
1216
|
+
await vi.waitFor(() => expect(handles.find((handle) => handle.request)?.request).toBeDefined());
|
|
1217
|
+
const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
|
|
1218
|
+
const active = handle.request(attentionCall(801));
|
|
1219
|
+
const terminal = handle.request(attentionCall(802));
|
|
1220
|
+
const terminalResponse = toOrgPlanAttentionToolResponse({
|
|
1221
|
+
action: 'resume',
|
|
1222
|
+
guidance: 'Sensitive terminal guidance.',
|
|
1223
|
+
});
|
|
1224
|
+
const accepted = await first.inject({
|
|
1225
|
+
method: 'POST',
|
|
1226
|
+
url: `/api/sessions/${sessionId}/attention/802/resolve`,
|
|
1227
|
+
payload: {
|
|
1228
|
+
operationKey: 'terminal-key',
|
|
1229
|
+
action: 'resume',
|
|
1230
|
+
guidance: 'Sensitive terminal guidance.',
|
|
1231
|
+
},
|
|
1232
|
+
});
|
|
1233
|
+
const resolvedAt = accepted.json().resolvedAt;
|
|
1234
|
+
await expect(terminal).resolves.toEqual(terminalResponse);
|
|
1235
|
+
await first.close();
|
|
1236
|
+
await expect(active).rejects.toMatchObject({ message: 'CODEX_SERVER_REQUEST_CANCELLED' });
|
|
1237
|
+
const reopened = await composeAuthorizedApp({
|
|
1238
|
+
root,
|
|
1239
|
+
dataDir,
|
|
1240
|
+
relyingParty,
|
|
1241
|
+
profiles,
|
|
1242
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1243
|
+
startAppServers: true,
|
|
1244
|
+
launchAppServer: liveAppServer(handles),
|
|
1245
|
+
});
|
|
1246
|
+
expect((await reopened.inject(`/api/sessions/${sessionId}/attention`)).json()).toMatchObject({
|
|
1247
|
+
requestId: '801',
|
|
1248
|
+
});
|
|
1249
|
+
const listed = (await reopened.inject('/api/sessions')).json();
|
|
1250
|
+
expect(JSON.stringify(listed)).toContain('801');
|
|
1251
|
+
await reopened.listen({ host: '127.0.0.1', port: 0 });
|
|
1252
|
+
await expect
|
|
1253
|
+
.poll(async () => (await reopened.inject(`/api/sessions/${sessionId}`)).json().state)
|
|
1254
|
+
.toBe('stopped');
|
|
1255
|
+
expect((await reopened.inject({ method: 'POST', url: `/api/sessions/${sessionId}/restore` }))
|
|
1256
|
+
.statusCode).toBe(200);
|
|
1257
|
+
const history = (await reopened.inject(`/api/sessions/${sessionId}/history`)).json();
|
|
1258
|
+
expect(history.interactions).toEqual(expect.arrayContaining([
|
|
1259
|
+
expect.objectContaining({ requestId: '801', resolvedAt: null }),
|
|
1260
|
+
expect.objectContaining({ requestId: '802', resolvedAt, outcome: 'answered' }),
|
|
1261
|
+
]));
|
|
1262
|
+
expect(JSON.stringify(history)).not.toContain('Sensitive terminal guidance.');
|
|
1263
|
+
expect((await reopened.inject({
|
|
1264
|
+
method: 'POST',
|
|
1265
|
+
url: `/api/sessions/${sessionId}/attention/802/resolve`,
|
|
1266
|
+
payload: { operationKey: 'terminal-key', action: 'resume' },
|
|
1267
|
+
})).json()).toEqual({ accepted: true, replayed: true, resolvedAt });
|
|
1268
|
+
expect((await reopened.inject({
|
|
1269
|
+
method: 'POST',
|
|
1270
|
+
url: `/api/sessions/${sessionId}/attention/802/resolve`,
|
|
1271
|
+
payload: { operationKey: 'other-terminal-key', action: 'resume' },
|
|
1272
|
+
})).json()).toEqual({ code: 'ATTENTION_OPERATION_STALE' });
|
|
1273
|
+
await reopened.close();
|
|
1274
|
+
});
|
|
1275
|
+
it('reports supported offline writers separately from legacy sessions without the attention capability', async () => {
|
|
1276
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1277
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1278
|
+
ownTemporaryPaths(root, dataDir);
|
|
1279
|
+
await mkdir(join(root, 'workspace'));
|
|
1280
|
+
const handles = [];
|
|
1281
|
+
const profiles = {
|
|
1282
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1283
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1284
|
+
};
|
|
1285
|
+
const first = await composeAuthorizedApp({
|
|
1286
|
+
root,
|
|
1287
|
+
dataDir,
|
|
1288
|
+
relyingParty,
|
|
1289
|
+
profiles,
|
|
1290
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1291
|
+
startAppServers: true,
|
|
1292
|
+
launchAppServer: liveAppServer(handles),
|
|
1293
|
+
});
|
|
1294
|
+
const supportedSession = await createComposedSession(first);
|
|
1295
|
+
const legacySession = await createComposedSession(first);
|
|
1296
|
+
await vi.waitFor(() => expect(handles.filter((handle) => handle.calls.includes('thread/start'))).toHaveLength(2));
|
|
1297
|
+
const started = handles.filter((handle) => handle.calls.includes('thread/start'));
|
|
1298
|
+
const supportedRequest = started[0].request(attentionCall(901));
|
|
1299
|
+
const legacyRequest = started[1].request(attentionCall(902));
|
|
1300
|
+
await vi.waitFor(async () => expect((await first.inject(`/api/sessions/${legacySession}/attention`)).statusCode).toBe(200));
|
|
1301
|
+
await first.close();
|
|
1302
|
+
await expect(supportedRequest).rejects.toMatchObject({
|
|
1303
|
+
message: 'CODEX_SERVER_REQUEST_CANCELLED',
|
|
1304
|
+
});
|
|
1305
|
+
await expect(legacyRequest).rejects.toMatchObject({
|
|
1306
|
+
message: 'CODEX_SERVER_REQUEST_CANCELLED',
|
|
1307
|
+
});
|
|
1308
|
+
const database = new DatabaseSync(join(dataDir, 'relay.sqlite'));
|
|
1309
|
+
database
|
|
1310
|
+
.prepare('UPDATE relay_sessions SET attention_tool_capability = NULL WHERE id = ?')
|
|
1311
|
+
.run(legacySession);
|
|
1312
|
+
database.close();
|
|
1313
|
+
const offline = await composeAuthorizedApp({
|
|
1314
|
+
root,
|
|
1315
|
+
dataDir,
|
|
1316
|
+
relyingParty,
|
|
1317
|
+
profiles,
|
|
1318
|
+
installedCodexVersion: null,
|
|
1319
|
+
});
|
|
1320
|
+
expect((await offline.inject({
|
|
1321
|
+
method: 'POST',
|
|
1322
|
+
url: `/api/sessions/${supportedSession}/attention/901/resolve`,
|
|
1323
|
+
payload: { operationKey: 'offline-supported', action: 'resume' },
|
|
1324
|
+
})).json()).toEqual({ code: 'ATTENTION_WRITER_UNAVAILABLE' });
|
|
1325
|
+
expect((await offline.inject({
|
|
1326
|
+
method: 'POST',
|
|
1327
|
+
url: `/api/sessions/${legacySession}/attention/902/resolve`,
|
|
1328
|
+
payload: { operationKey: 'legacy-thread', action: 'resume' },
|
|
1329
|
+
})).json()).toEqual({ code: 'ATTENTION_LEGACY_UNSUPPORTED' });
|
|
1330
|
+
await offline.close();
|
|
1331
|
+
});
|
|
1332
|
+
it('accepts bounded activity diagnostic and scheduler seams', async () => {
|
|
1333
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1334
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1335
|
+
ownTemporaryPaths(root, dataDir);
|
|
1336
|
+
const diagnostic = vi.fn();
|
|
1337
|
+
const app = await composeAuthorizedApp({
|
|
1338
|
+
root,
|
|
1339
|
+
dataDir,
|
|
1340
|
+
relyingParty,
|
|
1341
|
+
installedCodexVersion: null,
|
|
1342
|
+
profiles: {
|
|
1343
|
+
list: async () => [],
|
|
1344
|
+
require: async () => ({
|
|
1345
|
+
name: 'default',
|
|
1346
|
+
state: 'ok',
|
|
1347
|
+
status: 'ready',
|
|
1348
|
+
}),
|
|
1349
|
+
},
|
|
1350
|
+
activitySchedule: () => () => undefined,
|
|
1351
|
+
activityDiagnostic: diagnostic,
|
|
1352
|
+
});
|
|
1353
|
+
expect(diagnostic).not.toHaveBeenCalled();
|
|
1354
|
+
await app.close();
|
|
1355
|
+
});
|
|
1356
|
+
});
|
|
1357
|
+
describeCompositionConcern('lifecycle', () => {
|
|
1358
|
+
it('uses the relay root for detached history reads and Open never resumes', async () => {
|
|
1359
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1360
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1361
|
+
ownTemporaryPaths(root, dataDir);
|
|
1362
|
+
const launches = [];
|
|
1363
|
+
const app = await composeAuthorizedApp({
|
|
1364
|
+
root,
|
|
1365
|
+
dataDir,
|
|
1366
|
+
relyingParty,
|
|
1367
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1368
|
+
startAppServers: true,
|
|
1369
|
+
profiles: {
|
|
1370
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1371
|
+
require: async () => ({
|
|
1372
|
+
name: 'default',
|
|
1373
|
+
state: 'ok',
|
|
1374
|
+
status: 'ready',
|
|
1375
|
+
}),
|
|
1376
|
+
},
|
|
1377
|
+
launchAppServer: (input) => ({
|
|
1378
|
+
rpc: {
|
|
1379
|
+
request: async (method) => {
|
|
1380
|
+
launches.push({ cwd: input.cwd, method });
|
|
1381
|
+
if (method === 'thread/list')
|
|
1382
|
+
return { data: [{ id: 't', cwd: '/deleted', updatedAt: 1 }] };
|
|
1383
|
+
if (method === 'thread/read')
|
|
1384
|
+
return { thread: { turns: [] } };
|
|
1385
|
+
return {};
|
|
1386
|
+
},
|
|
1387
|
+
onNotification: () => () => { },
|
|
1388
|
+
onServerRequest: () => () => { },
|
|
1389
|
+
},
|
|
1390
|
+
close: () => { },
|
|
1391
|
+
}),
|
|
1392
|
+
});
|
|
1393
|
+
const opened = await app.inject({
|
|
1394
|
+
method: 'POST',
|
|
1395
|
+
url: '/api/sessions/recent-threads/open',
|
|
1396
|
+
payload: { threadId: 't', cwd: '/deleted' },
|
|
1397
|
+
});
|
|
1398
|
+
expect(opened.statusCode).toBe(202);
|
|
1399
|
+
expect(launches.filter((call) => call.method === 'thread/read').map((call) => call.cwd)).toEqual([root, root]);
|
|
1400
|
+
expect(launches.some((call) => call.method === 'thread/resume')).toBe(false);
|
|
1401
|
+
await app.close();
|
|
1402
|
+
});
|
|
1403
|
+
it('serves the relay without creating passkey state when access control is disabled', async () => {
|
|
1404
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1405
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1406
|
+
const homeDirectory = await mkdtemp(join(tmpdir(), 'gestalt-mobile-home-'));
|
|
1407
|
+
ownTemporaryPaths(root, dataDir, homeDirectory);
|
|
1408
|
+
const authorizationRandomBytes = vi.fn(() => new Uint8Array(32));
|
|
1409
|
+
const app = await composeRelayApp({
|
|
1410
|
+
root,
|
|
1411
|
+
dataDir,
|
|
1412
|
+
homeDirectory,
|
|
1413
|
+
relyingParty,
|
|
1414
|
+
passkeyAuthEnabled: false,
|
|
1415
|
+
authorizationRandomBytes,
|
|
1416
|
+
profiles: {
|
|
1417
|
+
list: async () => [],
|
|
1418
|
+
require: async () => ({
|
|
1419
|
+
name: 'default',
|
|
1420
|
+
state: 'ok',
|
|
1421
|
+
status: 'ready',
|
|
1422
|
+
}),
|
|
1423
|
+
},
|
|
1424
|
+
installedCodexVersion: null,
|
|
1425
|
+
});
|
|
1426
|
+
expect((await app.inject('/api/auth/status')).json()).toEqual({
|
|
1427
|
+
status: 'authenticated',
|
|
1428
|
+
publicOrigin: '',
|
|
1429
|
+
passkeyAuthEnabled: false,
|
|
1430
|
+
});
|
|
1431
|
+
expect((await app.inject('/api/bootstrap')).statusCode).toBe(200);
|
|
1432
|
+
expect((await app.inject({ method: 'POST', url: '/api/auth/login/options' })).statusCode).toBe(404);
|
|
1433
|
+
expect(authorizationRandomBytes).not.toHaveBeenCalled();
|
|
1434
|
+
await app.close();
|
|
1435
|
+
});
|
|
1436
|
+
it('inventory-classifies every production API route and reserves exactly six public entries', async () => {
|
|
1437
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1438
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1439
|
+
ownTemporaryPaths(root, dataDir);
|
|
1440
|
+
const app = await composeAuthorizedApp({
|
|
1441
|
+
root,
|
|
1442
|
+
dataDir,
|
|
1443
|
+
relyingParty,
|
|
1444
|
+
profiles: {
|
|
1445
|
+
list: async () => [],
|
|
1446
|
+
require: async () => ({
|
|
1447
|
+
name: 'default',
|
|
1448
|
+
state: 'ok',
|
|
1449
|
+
status: 'ready',
|
|
1450
|
+
}),
|
|
1451
|
+
},
|
|
1452
|
+
installedCodexVersion: null,
|
|
1453
|
+
});
|
|
1454
|
+
const routes = [
|
|
1455
|
+
['GET', '/api/auth/status', '/api/auth/status', 'public'],
|
|
1456
|
+
['POST', '/api/auth/login/options', '/api/auth/login/options', 'public'],
|
|
1457
|
+
['POST', '/api/auth/login/verify', '/api/auth/login/verify', 'public'],
|
|
1458
|
+
['POST', '/api/auth/register/options', '/api/auth/register/options', 'public'],
|
|
1459
|
+
['POST', '/api/auth/register/verify', '/api/auth/register/verify', 'public'],
|
|
1460
|
+
['HEAD', '/api/auth/status', '/api/auth/status', 'protected'],
|
|
1461
|
+
['POST', '/api/auth/logout', '/api/auth/logout', 'public'],
|
|
1462
|
+
['POST', '/api/auth/enrollment-tickets', '/api/auth/enrollment-tickets', 'protected'],
|
|
1463
|
+
[
|
|
1464
|
+
'GET',
|
|
1465
|
+
'/api/auth/enrollment-tickets/current',
|
|
1466
|
+
'/api/auth/enrollment-tickets/current',
|
|
1467
|
+
'protected',
|
|
1468
|
+
],
|
|
1469
|
+
[
|
|
1470
|
+
'HEAD',
|
|
1471
|
+
'/api/auth/enrollment-tickets/current',
|
|
1472
|
+
'/api/auth/enrollment-tickets/current',
|
|
1473
|
+
'protected',
|
|
1474
|
+
],
|
|
1475
|
+
[
|
|
1476
|
+
'DELETE',
|
|
1477
|
+
'/api/auth/enrollment-tickets/current',
|
|
1478
|
+
'/api/auth/enrollment-tickets/current',
|
|
1479
|
+
'protected',
|
|
1480
|
+
],
|
|
1481
|
+
['GET', '/api/auth/devices', '/api/auth/devices', 'protected'],
|
|
1482
|
+
['HEAD', '/api/auth/devices', '/api/auth/devices', 'protected'],
|
|
1483
|
+
['PATCH', '/api/auth/devices/:deviceId', '/api/auth/devices/device-1', 'protected'],
|
|
1484
|
+
['DELETE', '/api/auth/devices/:deviceId', '/api/auth/devices/device-1', 'protected'],
|
|
1485
|
+
['GET', '/api/bootstrap', '/api/bootstrap', 'protected'],
|
|
1486
|
+
['HEAD', '/api/bootstrap', '/api/bootstrap', 'protected'],
|
|
1487
|
+
['POST', '/api/sessions', '/api/sessions', 'protected'],
|
|
1488
|
+
['GET', '/api/sessions', '/api/sessions', 'protected'],
|
|
1489
|
+
['HEAD', '/api/sessions', '/api/sessions', 'protected'],
|
|
1490
|
+
['GET', '/api/sessions/recent-threads', '/api/sessions/recent-threads', 'protected'],
|
|
1491
|
+
['HEAD', '/api/sessions/recent-threads', '/api/sessions/recent-threads', 'protected'],
|
|
1492
|
+
['GET', '/api/sessions/:id', '/api/sessions/session-1', 'protected'],
|
|
1493
|
+
['GET', '/api/sessions/:id/attention', '/api/sessions/session-1/attention', 'protected'],
|
|
1494
|
+
[
|
|
1495
|
+
'POST',
|
|
1496
|
+
'/api/sessions/:id/activity/refresh',
|
|
1497
|
+
'/api/sessions/session-1/activity/refresh',
|
|
1498
|
+
'protected',
|
|
1499
|
+
],
|
|
1500
|
+
['HEAD', '/api/sessions/:id', '/api/sessions/session-1', 'protected'],
|
|
1501
|
+
['HEAD', '/api/sessions/:id/attention', '/api/sessions/session-1/attention', 'protected'],
|
|
1502
|
+
[
|
|
1503
|
+
'POST',
|
|
1504
|
+
'/api/sessions/:id/attention/:requestId/resolve',
|
|
1505
|
+
'/api/sessions/session-1/attention/request-1/resolve',
|
|
1506
|
+
'protected',
|
|
1507
|
+
],
|
|
1508
|
+
['POST', '/api/sessions/:id/model', '/api/sessions/session-1/model', 'protected'],
|
|
1509
|
+
['PUT', '/api/sessions/:id/autopilot', '/api/sessions/session-1/autopilot', 'protected'],
|
|
1510
|
+
['PUT', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
|
|
1511
|
+
['GET', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
|
|
1512
|
+
['HEAD', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
|
|
1513
|
+
['DELETE', '/api/sessions/:id/plan', '/api/sessions/session-1/plan', 'protected'],
|
|
1514
|
+
[
|
|
1515
|
+
'GET',
|
|
1516
|
+
'/api/workspaces/:workspaceId/plans',
|
|
1517
|
+
'/api/workspaces/workspace-1/plans',
|
|
1518
|
+
'protected',
|
|
1519
|
+
],
|
|
1520
|
+
[
|
|
1521
|
+
'HEAD',
|
|
1522
|
+
'/api/workspaces/:workspaceId/plans',
|
|
1523
|
+
'/api/workspaces/workspace-1/plans',
|
|
1524
|
+
'protected',
|
|
1525
|
+
],
|
|
1526
|
+
[
|
|
1527
|
+
'GET',
|
|
1528
|
+
'/api/workspaces/:workspaceId/plans/:planName',
|
|
1529
|
+
'/api/workspaces/workspace-1/plans/plan.org',
|
|
1530
|
+
'protected',
|
|
1531
|
+
],
|
|
1532
|
+
[
|
|
1533
|
+
'HEAD',
|
|
1534
|
+
'/api/workspaces/:workspaceId/plans/:planName',
|
|
1535
|
+
'/api/workspaces/workspace-1/plans/plan.org',
|
|
1536
|
+
'protected',
|
|
1537
|
+
],
|
|
1538
|
+
[
|
|
1539
|
+
'GET',
|
|
1540
|
+
'/api/git/repositories/:workspaceId',
|
|
1541
|
+
'/api/git/repositories/workspace-1',
|
|
1542
|
+
'protected',
|
|
1543
|
+
],
|
|
1544
|
+
[
|
|
1545
|
+
'HEAD',
|
|
1546
|
+
'/api/git/repositories/:workspaceId',
|
|
1547
|
+
'/api/git/repositories/workspace-1',
|
|
1548
|
+
'protected',
|
|
1549
|
+
],
|
|
1550
|
+
[
|
|
1551
|
+
'POST',
|
|
1552
|
+
'/api/git/repositories/:workspaceId/push',
|
|
1553
|
+
'/api/git/repositories/workspace-1/push',
|
|
1554
|
+
'protected',
|
|
1555
|
+
],
|
|
1556
|
+
[
|
|
1557
|
+
'POST',
|
|
1558
|
+
'/api/git/repositories/:workspaceId/refresh',
|
|
1559
|
+
'/api/git/repositories/workspace-1/refresh',
|
|
1560
|
+
'protected',
|
|
1561
|
+
],
|
|
1562
|
+
[
|
|
1563
|
+
'POST',
|
|
1564
|
+
'/api/git/repositories/:workspaceId/pull',
|
|
1565
|
+
'/api/git/repositories/workspace-1/pull',
|
|
1566
|
+
'protected',
|
|
1567
|
+
],
|
|
1568
|
+
[
|
|
1569
|
+
'POST',
|
|
1570
|
+
'/api/git/repositories/:workspaceId/checkout',
|
|
1571
|
+
'/api/git/repositories/workspace-1/checkout',
|
|
1572
|
+
'protected',
|
|
1573
|
+
],
|
|
1574
|
+
['POST', '/api/git/clone', '/api/git/clone', 'protected'],
|
|
1575
|
+
['GET', '/api/skills', '/api/skills', 'protected'],
|
|
1576
|
+
['HEAD', '/api/skills', '/api/skills', 'protected'],
|
|
1577
|
+
['GET', '/api/skill-profiles', '/api/skill-profiles', 'protected'],
|
|
1578
|
+
['HEAD', '/api/skill-profiles', '/api/skill-profiles', 'protected'],
|
|
1579
|
+
['PUT', '/api/skill-profiles/:name', '/api/skill-profiles/default', 'protected'],
|
|
1580
|
+
['DELETE', '/api/skill-profiles/:name', '/api/skill-profiles/default', 'protected'],
|
|
1581
|
+
];
|
|
1582
|
+
const routePaths = [];
|
|
1583
|
+
const inventory = app
|
|
1584
|
+
.printRoutes({ commonPrefix: false })
|
|
1585
|
+
.split('\n')
|
|
1586
|
+
.flatMap((line) => {
|
|
1587
|
+
const match = line.match(/^(.*)[├└]── (\/\S+) \(([^)]+)\)$/);
|
|
1588
|
+
if (!match)
|
|
1589
|
+
return [];
|
|
1590
|
+
const depth = match[1].length / 4;
|
|
1591
|
+
routePaths.splice(depth);
|
|
1592
|
+
routePaths[depth] = depth === 0 ? match[2] : `${routePaths[depth - 1]}${match[2]}`;
|
|
1593
|
+
if (!routePaths[depth].startsWith('/api/'))
|
|
1594
|
+
return [];
|
|
1595
|
+
return match[3].split(', ').map((method) => `${method} ${routePaths[depth]}`);
|
|
1596
|
+
})
|
|
1597
|
+
.sort();
|
|
1598
|
+
const expectedInventory = routes.map(([method, pattern]) => `${method} ${pattern}`).sort();
|
|
1599
|
+
expect(inventory).toEqual(expectedInventory);
|
|
1600
|
+
expect(routes.filter(([, , , access]) => access === 'public')).toEqual([
|
|
1601
|
+
['GET', '/api/auth/status', '/api/auth/status', 'public'],
|
|
1602
|
+
['POST', '/api/auth/login/options', '/api/auth/login/options', 'public'],
|
|
1603
|
+
['POST', '/api/auth/login/verify', '/api/auth/login/verify', 'public'],
|
|
1604
|
+
['POST', '/api/auth/register/options', '/api/auth/register/options', 'public'],
|
|
1605
|
+
['POST', '/api/auth/register/verify', '/api/auth/register/verify', 'public'],
|
|
1606
|
+
['POST', '/api/auth/logout', '/api/auth/logout', 'public'],
|
|
1607
|
+
]);
|
|
1608
|
+
const unauthorized = await createUnauthorizedProductionApp(root, dataDir);
|
|
1609
|
+
for (const [method, pattern, url, access] of routes) {
|
|
1610
|
+
const response = await unauthorized.inject({
|
|
1611
|
+
method,
|
|
1612
|
+
url,
|
|
1613
|
+
headers: method === 'GET' || method === 'HEAD' ? {} : { origin: relyingParty.publicOrigin },
|
|
1614
|
+
});
|
|
1615
|
+
if (access === 'public') {
|
|
1616
|
+
expect(response.statusCode, `${method} ${pattern}`).not.toBe(401);
|
|
1617
|
+
expect(response.body, `${method} ${pattern}`).not.toContain('AUTH_REQUIRED');
|
|
1618
|
+
}
|
|
1619
|
+
else {
|
|
1620
|
+
expect(response.statusCode, `${method} ${pattern}`).toBe(401);
|
|
1621
|
+
if (method !== 'HEAD')
|
|
1622
|
+
expect(response.json(), `${method} ${pattern}`).toMatchObject({
|
|
1623
|
+
code: 'AUTH_REQUIRED',
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
await unauthorized.close();
|
|
1628
|
+
await app.close();
|
|
1629
|
+
});
|
|
1630
|
+
});
|
|
1631
|
+
describeCompositionConcern('authorization', () => {
|
|
1632
|
+
it('shares one authorization owner across independently composed relay databases and closes handles independently', async () => {
|
|
1633
|
+
const rootOne = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1634
|
+
const rootTwo = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1635
|
+
const dataOne = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1636
|
+
const dataTwo = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1637
|
+
const sharedHome = await mkdtemp(join(tmpdir(), 'gestalt-mobile-home-'));
|
|
1638
|
+
ownTemporaryPaths(rootOne, rootTwo, dataOne, dataTwo, sharedHome);
|
|
1639
|
+
const profiles = {
|
|
1640
|
+
list: async () => [],
|
|
1641
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1642
|
+
};
|
|
1643
|
+
const firstHandle = new Uint8Array(32).fill(1);
|
|
1644
|
+
const secondHandle = new Uint8Array(32).fill(9);
|
|
1645
|
+
const first = await composeRelayApp({
|
|
1646
|
+
root: rootOne,
|
|
1647
|
+
dataDir: dataOne,
|
|
1648
|
+
homeDirectory: sharedHome,
|
|
1649
|
+
relyingParty,
|
|
1650
|
+
profiles,
|
|
1651
|
+
installedCodexVersion: null,
|
|
1652
|
+
authorizationRandomBytes: () => firstHandle,
|
|
1653
|
+
});
|
|
1654
|
+
const second = await composeRelayApp({
|
|
1655
|
+
root: rootTwo,
|
|
1656
|
+
dataDir: dataTwo,
|
|
1657
|
+
homeDirectory: sharedHome,
|
|
1658
|
+
relyingParty,
|
|
1659
|
+
profiles,
|
|
1660
|
+
installedCodexVersion: null,
|
|
1661
|
+
authorizationRandomBytes: () => secondHandle,
|
|
1662
|
+
});
|
|
1663
|
+
await first.close();
|
|
1664
|
+
expect((await second.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200);
|
|
1665
|
+
const store = new SqliteAuthorizationStore(sharedHome, relyingParty);
|
|
1666
|
+
expect(store.readOwner()?.userHandle).toEqual(firstHandle);
|
|
1667
|
+
store.close();
|
|
1668
|
+
await second.close();
|
|
1669
|
+
});
|
|
1670
|
+
it('rejects malformed authorization randomness before opening a durable auth handle', async () => {
|
|
1671
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1672
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1673
|
+
const home = await mkdtemp(join(tmpdir(), 'gestalt-mobile-home-'));
|
|
1674
|
+
ownTemporaryPaths(root, dataDir, home);
|
|
1675
|
+
await expect(composeRelayApp({
|
|
1676
|
+
root,
|
|
1677
|
+
dataDir,
|
|
1678
|
+
homeDirectory: home,
|
|
1679
|
+
relyingParty,
|
|
1680
|
+
profiles: {
|
|
1681
|
+
list: async () => [],
|
|
1682
|
+
require: async () => ({
|
|
1683
|
+
name: 'default',
|
|
1684
|
+
state: 'ok',
|
|
1685
|
+
status: 'ready',
|
|
1686
|
+
}),
|
|
1687
|
+
},
|
|
1688
|
+
installedCodexVersion: null,
|
|
1689
|
+
authorizationRandomBytes: () => new Uint8Array(31),
|
|
1690
|
+
})).rejects.toThrow('exactly 32');
|
|
1691
|
+
const store = new SqliteAuthorizationStore(home, relyingParty);
|
|
1692
|
+
expect(store.readOwner()).toBeNull();
|
|
1693
|
+
store.close();
|
|
1694
|
+
});
|
|
1695
|
+
it('closes authorization and relay handles when app construction fails after owner initialization', async () => {
|
|
1696
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1697
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1698
|
+
const home = await mkdtemp(join(tmpdir(), 'gestalt-mobile-home-'));
|
|
1699
|
+
const notDirectory = join(root, 'not-a-directory');
|
|
1700
|
+
ownTemporaryPaths(root, dataDir, home);
|
|
1701
|
+
await writeFile(notDirectory, 'x');
|
|
1702
|
+
await expect(composeRelayApp({
|
|
1703
|
+
root,
|
|
1704
|
+
dataDir,
|
|
1705
|
+
homeDirectory: home,
|
|
1706
|
+
staticDir: notDirectory,
|
|
1707
|
+
relyingParty,
|
|
1708
|
+
profiles: {
|
|
1709
|
+
list: async () => [],
|
|
1710
|
+
require: async () => ({
|
|
1711
|
+
name: 'default',
|
|
1712
|
+
state: 'ok',
|
|
1713
|
+
status: 'ready',
|
|
1714
|
+
}),
|
|
1715
|
+
},
|
|
1716
|
+
installedCodexVersion: null,
|
|
1717
|
+
authorizationRandomBytes: () => new Uint8Array(32).fill(7),
|
|
1718
|
+
})).rejects.toThrow();
|
|
1719
|
+
const store = new SqliteAuthorizationStore(home, relyingParty);
|
|
1720
|
+
expect(store.readOwner()?.userHandle).toEqual(new Uint8Array(32).fill(7));
|
|
1721
|
+
store.close();
|
|
1722
|
+
});
|
|
1723
|
+
it('closes the relay handle when authorization initialization rejects an RP migration', async () => {
|
|
1724
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1725
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1726
|
+
const home = await mkdtemp(join(tmpdir(), 'gestalt-mobile-home-'));
|
|
1727
|
+
ownTemporaryPaths(root, dataDir, home);
|
|
1728
|
+
const seeded = new SqliteAuthorizationStore(home, relyingParty);
|
|
1729
|
+
const owner = { id: localOwnerId('local-owner'), userHandle: new Uint8Array(32).fill(1) };
|
|
1730
|
+
seeded.initializeOwner(owner.userHandle);
|
|
1731
|
+
seeded.claimFirstDevice(owner, {
|
|
1732
|
+
id: authorizedDeviceId('device'),
|
|
1733
|
+
credentialId: webAuthnCredentialId('credential'),
|
|
1734
|
+
publicKey: new Uint8Array([1]),
|
|
1735
|
+
counter: 0,
|
|
1736
|
+
transports: ['internal'],
|
|
1737
|
+
deviceType: 'singleDevice',
|
|
1738
|
+
backedUp: false,
|
|
1739
|
+
nickname: deviceNickname('Device'),
|
|
1740
|
+
createdAt: '2026-08-02T00:00:00.000Z',
|
|
1741
|
+
});
|
|
1742
|
+
seeded.close();
|
|
1743
|
+
const migrated = {
|
|
1744
|
+
publicOrigin: 'https://other.example',
|
|
1745
|
+
rpId: 'other.example',
|
|
1746
|
+
rpName: 'Gestalt Mobile',
|
|
1747
|
+
};
|
|
1748
|
+
await expect(composeRelayApp({
|
|
1749
|
+
root,
|
|
1750
|
+
dataDir,
|
|
1751
|
+
homeDirectory: home,
|
|
1752
|
+
relyingParty: migrated,
|
|
1753
|
+
profiles: {
|
|
1754
|
+
list: async () => [],
|
|
1755
|
+
require: async () => ({
|
|
1756
|
+
name: 'default',
|
|
1757
|
+
state: 'ok',
|
|
1758
|
+
status: 'ready',
|
|
1759
|
+
}),
|
|
1760
|
+
},
|
|
1761
|
+
installedCodexVersion: null,
|
|
1762
|
+
authorizationRandomBytes: () => new Uint8Array(32).fill(2),
|
|
1763
|
+
})).rejects.toThrow('hostname changed');
|
|
1764
|
+
const reopened = new SqliteAuthorizationStore(home, relyingParty);
|
|
1765
|
+
expect(reopened.listAuthorizedDevices()).toHaveLength(1);
|
|
1766
|
+
reopened.close();
|
|
1767
|
+
});
|
|
1768
|
+
it('rejects a relying-party identity that does not match its canonical origin', async () => {
|
|
1769
|
+
await expect(composeRelayApp({
|
|
1770
|
+
root: '/unused',
|
|
1771
|
+
relyingParty: { ...relyingParty, rpId: 'other.example' },
|
|
1772
|
+
profiles: {
|
|
1773
|
+
list: async () => [],
|
|
1774
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1775
|
+
},
|
|
1776
|
+
installedCodexVersion: null,
|
|
1777
|
+
})).rejects.toThrow('Invalid WebAuthn relying-party configuration');
|
|
1778
|
+
});
|
|
1779
|
+
});
|
|
1780
|
+
describeCompositionConcern('sessions', () => {
|
|
1781
|
+
it('does not resolve a Git operation target outside the configured root', async () => {
|
|
1782
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1783
|
+
const outside = await mkdtemp(join(tmpdir(), 'gestalt-mobile-outside-'));
|
|
1784
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1785
|
+
ownTemporaryPaths(root, outside, dataDir);
|
|
1786
|
+
await mkdir(join(outside, '.git'));
|
|
1787
|
+
await symlink(outside, join(root, 'escape'));
|
|
1788
|
+
const app = await composeAuthorizedApp({
|
|
1789
|
+
root,
|
|
1790
|
+
dataDir,
|
|
1791
|
+
relyingParty,
|
|
1792
|
+
profiles: {
|
|
1793
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1794
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1795
|
+
},
|
|
1796
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1797
|
+
});
|
|
1798
|
+
const response = await app.inject({
|
|
1799
|
+
method: 'GET',
|
|
1800
|
+
url: `/api/git/repositories/${workspaceId(outside)}`,
|
|
1801
|
+
});
|
|
1802
|
+
expect(response.statusCode).toBe(404);
|
|
1803
|
+
expect(response.json()).toEqual({ code: 'WORKSPACE_NOT_FOUND' });
|
|
1804
|
+
await app.close();
|
|
1805
|
+
});
|
|
1806
|
+
it('persists a catalog-selected session under the configured data directory', async () => {
|
|
1807
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1808
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1809
|
+
ownTemporaryPaths(root, dataDir);
|
|
1810
|
+
await mkdir(join(root, 'workspace'));
|
|
1811
|
+
const app = await composeAuthorizedApp({
|
|
1812
|
+
root,
|
|
1813
|
+
dataDir,
|
|
1814
|
+
relyingParty,
|
|
1815
|
+
profiles: {
|
|
1816
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1817
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1818
|
+
},
|
|
1819
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1820
|
+
launchAppServer: () => fakeAppServer([]),
|
|
1821
|
+
});
|
|
1822
|
+
const bootstrap = await app.inject({ method: 'GET', url: '/api/bootstrap' });
|
|
1823
|
+
const workspace = bootstrap
|
|
1824
|
+
.json()
|
|
1825
|
+
.workspaces[0]?.children.find((item) => item.name === 'workspace');
|
|
1826
|
+
expect(workspace).toBeDefined();
|
|
1827
|
+
const created = await app.inject({
|
|
1828
|
+
method: 'POST',
|
|
1829
|
+
url: '/api/sessions',
|
|
1830
|
+
payload: { workspaceId: workspace.id, profile: 'default' },
|
|
1831
|
+
});
|
|
1832
|
+
expect(created.statusCode).toBe(202);
|
|
1833
|
+
const restored = await app.inject({
|
|
1834
|
+
method: 'GET',
|
|
1835
|
+
url: `/api/sessions/${created.json().id}`,
|
|
1836
|
+
});
|
|
1837
|
+
expect(restored.json()).toMatchObject({
|
|
1838
|
+
workspaceId: workspace.id,
|
|
1839
|
+
workspacePath: join(root, 'workspace'),
|
|
1840
|
+
});
|
|
1841
|
+
await app.close();
|
|
1842
|
+
});
|
|
1843
|
+
it('hands a replayed session-owned plan replacement off to its live close event exactly once', async () => {
|
|
1844
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1845
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1846
|
+
ownTemporaryPaths(root, dataDir);
|
|
1847
|
+
const workspacePath = join(root, 'workspace');
|
|
1848
|
+
await mkdir(workspacePath);
|
|
1849
|
+
const planPath = join(workspacePath, 'plan.org');
|
|
1850
|
+
await writeFile(planPath, `#+TITLE: Completed plan
|
|
1851
|
+
* DONE [#A] Closeable work
|
|
1852
|
+
:PROPERTIES:
|
|
1853
|
+
:ID: closeable-work
|
|
1854
|
+
:SKILLS: $gestalt:org-plan
|
|
1855
|
+
:REVIEW_STATUS: UNREVIEWED
|
|
1856
|
+
:END:
|
|
1857
|
+
- Effort :: Small
|
|
1858
|
+
- Goal :: Exercise session event composition.
|
|
1859
|
+
- Notes :: Complete.
|
|
1860
|
+
`);
|
|
1861
|
+
const profiles = {
|
|
1862
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1863
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1864
|
+
};
|
|
1865
|
+
const app = await composeAuthorizedApp({
|
|
1866
|
+
root,
|
|
1867
|
+
dataDir,
|
|
1868
|
+
relyingParty,
|
|
1869
|
+
profiles,
|
|
1870
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1871
|
+
startAppServers: true,
|
|
1872
|
+
launchAppServer: () => fakeAppServer([]),
|
|
1873
|
+
});
|
|
1874
|
+
const workspace = (await app.inject('/api/bootstrap'))
|
|
1875
|
+
.json()
|
|
1876
|
+
.workspaces[0]?.children.find((item) => item.name === 'workspace');
|
|
1877
|
+
const created = await app.inject({
|
|
1878
|
+
method: 'POST',
|
|
1879
|
+
url: '/api/sessions',
|
|
1880
|
+
payload: { workspaceId: workspace.id, profile: 'default' },
|
|
1881
|
+
});
|
|
1882
|
+
const sessionId = created.json().id;
|
|
1883
|
+
await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), planPath), JSON.stringify({
|
|
1884
|
+
schemaVersion: 1,
|
|
1885
|
+
planPath,
|
|
1886
|
+
reason: 'supervision-start',
|
|
1887
|
+
updatedAt: '2026-08-01T00:00:00.000Z',
|
|
1888
|
+
}));
|
|
1889
|
+
await expect
|
|
1890
|
+
.poll(async () => (await app.inject(`/api/sessions/${sessionId}/plan`)).statusCode)
|
|
1891
|
+
.toBe(200);
|
|
1892
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
1893
|
+
const address = app.server.address();
|
|
1894
|
+
if (!address || typeof address === 'string')
|
|
1895
|
+
throw new Error('Expected TCP listener');
|
|
1896
|
+
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
|
|
1897
|
+
headers: {
|
|
1898
|
+
origin: relyingParty.publicOrigin,
|
|
1899
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
1900
|
+
},
|
|
1901
|
+
});
|
|
1902
|
+
const messages = [];
|
|
1903
|
+
socket.on('message', (data) => messages.push(JSON.parse(String(data))));
|
|
1904
|
+
await once(socket, 'open');
|
|
1905
|
+
await vi.waitFor(() => expect(messages.some((message) => message.event.type === 'plan.updated')).toBe(true));
|
|
1906
|
+
const updatedIndex = messages.findIndex((message) => message.event.type === 'plan.updated');
|
|
1907
|
+
expect(messages[updatedIndex]).toMatchObject({
|
|
1908
|
+
type: 'relay.event',
|
|
1909
|
+
event: {
|
|
1910
|
+
type: 'plan.updated',
|
|
1911
|
+
payload: {
|
|
1912
|
+
plan: { title: 'Completed plan', allDone: true },
|
|
1913
|
+
reason: 'supervision-start',
|
|
1914
|
+
},
|
|
1915
|
+
},
|
|
1916
|
+
});
|
|
1917
|
+
expect((await app.inject({ method: 'DELETE', url: `/api/sessions/${sessionId}/plan` })).statusCode).toBe(204);
|
|
1918
|
+
await vi.waitFor(() => expect(messages.some((message) => message.event.type === 'plan.closed')).toBe(true));
|
|
1919
|
+
const closedIndex = messages.findIndex((message) => message.event.type === 'plan.closed');
|
|
1920
|
+
expect(messages[closedIndex]).toMatchObject({
|
|
1921
|
+
type: 'relay.event',
|
|
1922
|
+
event: { type: 'plan.closed', payload: {} },
|
|
1923
|
+
});
|
|
1924
|
+
expect(closedIndex).toBeGreaterThan(updatedIndex);
|
|
1925
|
+
const replayedSequence = messages[updatedIndex].event.sequence;
|
|
1926
|
+
const liveSequence = messages[closedIndex].event.sequence;
|
|
1927
|
+
expect(replayedSequence).toBeGreaterThan(0);
|
|
1928
|
+
expect(liveSequence).toBeGreaterThan(replayedSequence);
|
|
1929
|
+
expect(new Set([replayedSequence, liveSequence]).size).toBe(2);
|
|
1930
|
+
expect(messages.filter((message) => message.event.type === 'plan.updated')).toHaveLength(1);
|
|
1931
|
+
expect(messages.filter((message) => message.event.type === 'plan.closed')).toHaveLength(1);
|
|
1932
|
+
const updatesBeforeResync = messages.filter((message) => message.event.type === 'plan.updated').length;
|
|
1933
|
+
await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), planPath), JSON.stringify({
|
|
1934
|
+
schemaVersion: 1,
|
|
1935
|
+
planPath,
|
|
1936
|
+
reason: 'same-plan-resync',
|
|
1937
|
+
updatedAt: '2026-08-01T00:00:01.000Z',
|
|
1938
|
+
}));
|
|
1939
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1940
|
+
expect(messages.filter((message) => message.event.type === 'plan.updated')).toHaveLength(updatesBeforeResync);
|
|
1941
|
+
socket.close();
|
|
1942
|
+
await app.close();
|
|
1943
|
+
const restarted = await composeAuthorizedApp({
|
|
1944
|
+
root,
|
|
1945
|
+
dataDir,
|
|
1946
|
+
relyingParty,
|
|
1947
|
+
profiles,
|
|
1948
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1949
|
+
startAppServers: true,
|
|
1950
|
+
launchAppServer: () => fakeAppServer([]),
|
|
1951
|
+
});
|
|
1952
|
+
await restarted.listen({ host: '127.0.0.1', port: 0 });
|
|
1953
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1954
|
+
expect((await restarted.inject(`/api/sessions/${sessionId}/plan`)).statusCode).toBe(204);
|
|
1955
|
+
const nextPlanPath = join(workspacePath, 'next-plan.org');
|
|
1956
|
+
await writeFile(nextPlanPath, `#+TITLE: Different plan
|
|
1957
|
+
* DONE [#A] Different work
|
|
1958
|
+
:PROPERTIES:
|
|
1959
|
+
:ID: different-work
|
|
1960
|
+
:SKILLS: $gestalt:org-plan
|
|
1961
|
+
:REVIEW_STATUS: UNREVIEWED
|
|
1962
|
+
:END:
|
|
1963
|
+
- Effort :: Small
|
|
1964
|
+
- Goal :: Prove a different plan can replace a dismissed one.
|
|
1965
|
+
- Notes :: Complete.
|
|
1966
|
+
`);
|
|
1967
|
+
await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), nextPlanPath), JSON.stringify({
|
|
1968
|
+
schemaVersion: 1,
|
|
1969
|
+
planPath: nextPlanPath,
|
|
1970
|
+
reason: 'different-plan',
|
|
1971
|
+
updatedAt: '2026-08-01T00:00:02.000Z',
|
|
1972
|
+
}));
|
|
1973
|
+
await expect
|
|
1974
|
+
.poll(async () => (await restarted.inject(`/api/sessions/${sessionId}/plan`)).json().title)
|
|
1975
|
+
.toBe('Different plan');
|
|
1976
|
+
await restarted.close();
|
|
1977
|
+
});
|
|
1978
|
+
it('detaches active persisted threads when the relay restarts without resuming a writer', async () => {
|
|
1979
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
1980
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
1981
|
+
ownTemporaryPaths(root, dataDir);
|
|
1982
|
+
await mkdir(join(root, 'workspace'));
|
|
1983
|
+
const profiles = {
|
|
1984
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
1985
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
1986
|
+
};
|
|
1987
|
+
const firstCalls = [];
|
|
1988
|
+
const first = await composeAuthorizedApp({
|
|
1989
|
+
root,
|
|
1990
|
+
dataDir,
|
|
1991
|
+
relyingParty,
|
|
1992
|
+
profiles,
|
|
1993
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
1994
|
+
startAppServers: true,
|
|
1995
|
+
launchAppServer: () => fakeAppServer(firstCalls),
|
|
1996
|
+
});
|
|
1997
|
+
const workspace = (await first.inject('/api/bootstrap'))
|
|
1998
|
+
.json()
|
|
1999
|
+
.workspaces[0]?.children.find((item) => item.name === 'workspace');
|
|
2000
|
+
expect(workspace).toBeDefined();
|
|
2001
|
+
const created = await first.inject({
|
|
2002
|
+
method: 'POST',
|
|
2003
|
+
url: '/api/sessions',
|
|
2004
|
+
payload: { workspaceId: workspace.id, profile: 'default' },
|
|
2005
|
+
});
|
|
2006
|
+
expect(created.statusCode).toBe(202);
|
|
2007
|
+
expect(firstCalls).toEqual([
|
|
2008
|
+
'initialize',
|
|
2009
|
+
'model/list',
|
|
2010
|
+
'initialize',
|
|
2011
|
+
'model/list',
|
|
2012
|
+
'initialize',
|
|
2013
|
+
'skills/list',
|
|
2014
|
+
'initialize',
|
|
2015
|
+
'skills/list',
|
|
2016
|
+
'initialize',
|
|
2017
|
+
'thread/start',
|
|
2018
|
+
'thread/read',
|
|
2019
|
+
]);
|
|
2020
|
+
await first.close();
|
|
2021
|
+
const secondCalls = [];
|
|
2022
|
+
const second = await composeAuthorizedApp({
|
|
2023
|
+
root,
|
|
2024
|
+
dataDir,
|
|
2025
|
+
relyingParty,
|
|
2026
|
+
profiles,
|
|
2027
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
2028
|
+
startAppServers: true,
|
|
2029
|
+
launchAppServer: () => fakeAppServer(secondCalls),
|
|
2030
|
+
});
|
|
2031
|
+
expect(secondCalls).toEqual([]);
|
|
2032
|
+
await second.listen({ host: '127.0.0.1', port: 0 });
|
|
2033
|
+
await expect
|
|
2034
|
+
.poll(() => secondCalls, { timeout: 1_000 })
|
|
2035
|
+
.toEqual(['initialize', 'skills/list']);
|
|
2036
|
+
const restored = await second.inject(`/api/sessions/${created.json().id}`);
|
|
2037
|
+
expect(restored.json()).toMatchObject({
|
|
2038
|
+
threadId: 'thread-1',
|
|
2039
|
+
state: 'stopped',
|
|
2040
|
+
agentActivity: { confidence: 'stale', root: { state: 'disconnected' } },
|
|
2041
|
+
});
|
|
2042
|
+
await second.close();
|
|
2043
|
+
});
|
|
2044
|
+
it('closes an active Codex child during graceful relay shutdown', async () => {
|
|
2045
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
2046
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
2047
|
+
ownTemporaryPaths(root, dataDir);
|
|
2048
|
+
await mkdir(join(root, 'workspace'));
|
|
2049
|
+
let closed = 0;
|
|
2050
|
+
const app = await composeAuthorizedApp({
|
|
2051
|
+
root,
|
|
2052
|
+
dataDir,
|
|
2053
|
+
relyingParty,
|
|
2054
|
+
profiles: {
|
|
2055
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
2056
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
2057
|
+
},
|
|
2058
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
2059
|
+
startAppServers: true,
|
|
2060
|
+
launchAppServer: () => ({
|
|
2061
|
+
rpc: {
|
|
2062
|
+
request: async (method, params) => {
|
|
2063
|
+
if (method === 'model/list')
|
|
2064
|
+
return { data: [{ id: 'gpt-5.6-terra' }] };
|
|
2065
|
+
if (method === 'skills/list')
|
|
2066
|
+
return {
|
|
2067
|
+
data: [{ cwd: params.cwds[0], skills: [], errors: [] }],
|
|
2068
|
+
};
|
|
2069
|
+
return method === 'thread/start' ? { thread: { id: 'thread-1' } } : {};
|
|
2070
|
+
},
|
|
2071
|
+
onNotification: () => () => { },
|
|
2072
|
+
onServerRequest: () => () => { },
|
|
2073
|
+
},
|
|
2074
|
+
close: () => {
|
|
2075
|
+
closed += 1;
|
|
2076
|
+
},
|
|
2077
|
+
onExit: () => () => { },
|
|
2078
|
+
}),
|
|
2079
|
+
});
|
|
2080
|
+
const workspace = (await app.inject('/api/bootstrap'))
|
|
2081
|
+
.json()
|
|
2082
|
+
.workspaces[0]?.children.find((item) => item.name === 'workspace');
|
|
2083
|
+
await app.inject({
|
|
2084
|
+
method: 'POST',
|
|
2085
|
+
url: '/api/sessions',
|
|
2086
|
+
payload: { workspaceId: workspace.id, profile: 'default' },
|
|
2087
|
+
});
|
|
2088
|
+
await app.close();
|
|
2089
|
+
// Model and skill catalogs run for bootstrap and session start; the active child closes with the relay.
|
|
2090
|
+
expect(closed).toBe(5);
|
|
2091
|
+
});
|
|
2092
|
+
it('reconciles an interaction cleared upstream as no longer pending', async () => {
|
|
2093
|
+
const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
|
|
2094
|
+
const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
|
|
2095
|
+
ownTemporaryPaths(root, dataDir);
|
|
2096
|
+
await mkdir(join(root, 'workspace'));
|
|
2097
|
+
const handles = [];
|
|
2098
|
+
const activityCallbacks = [];
|
|
2099
|
+
const activityDiagnostic = vi.fn();
|
|
2100
|
+
const app = await composeAuthorizedApp({
|
|
2101
|
+
root,
|
|
2102
|
+
dataDir,
|
|
2103
|
+
relyingParty,
|
|
2104
|
+
profiles: {
|
|
2105
|
+
list: async () => [{ name: 'default', state: 'ok', status: 'ready' }],
|
|
2106
|
+
require: async () => ({ name: 'default', state: 'ok', status: 'ready' }),
|
|
2107
|
+
},
|
|
2108
|
+
installedCodexVersion: 'codex-cli 0.144.3',
|
|
2109
|
+
startAppServers: true,
|
|
2110
|
+
activitySchedule: (callback) => {
|
|
2111
|
+
activityCallbacks.push(callback);
|
|
2112
|
+
return () => undefined;
|
|
2113
|
+
},
|
|
2114
|
+
activityDiagnostic,
|
|
2115
|
+
launchAppServer: () => {
|
|
2116
|
+
const handle = { calls: [] };
|
|
2117
|
+
handles.push(handle);
|
|
2118
|
+
return {
|
|
2119
|
+
rpc: {
|
|
2120
|
+
request: async (method, params) => {
|
|
2121
|
+
handle.calls.push(method);
|
|
2122
|
+
if (method === 'thread/start')
|
|
2123
|
+
return { thread: { id: 'thread-1' } };
|
|
2124
|
+
if (method === 'thread/read') {
|
|
2125
|
+
if (handle.failReads)
|
|
2126
|
+
throw new Error('READ_DOWN');
|
|
2127
|
+
return { thread: { turns: [] } };
|
|
2128
|
+
}
|
|
2129
|
+
if (method === 'model/list')
|
|
2130
|
+
return { data: [{ id: 'gpt-5.6-terra' }] };
|
|
2131
|
+
if (method === 'skills/list')
|
|
2132
|
+
return {
|
|
2133
|
+
data: [{ cwd: params.cwds[0], skills: [], errors: [] }],
|
|
2134
|
+
};
|
|
2135
|
+
return {};
|
|
2136
|
+
},
|
|
2137
|
+
onNotification: (listener) => {
|
|
2138
|
+
handle.notify = listener;
|
|
2139
|
+
return () => { };
|
|
2140
|
+
},
|
|
2141
|
+
onServerRequest: (listener) => {
|
|
2142
|
+
handle.request = listener;
|
|
2143
|
+
return () => { };
|
|
2144
|
+
},
|
|
2145
|
+
},
|
|
2146
|
+
close: () => { },
|
|
2147
|
+
onExit: () => () => { },
|
|
2148
|
+
};
|
|
2149
|
+
},
|
|
2150
|
+
});
|
|
2151
|
+
const workspace = (await app.inject('/api/bootstrap'))
|
|
2152
|
+
.json()
|
|
2153
|
+
.workspaces[0]?.children.find((item) => item.name === 'workspace');
|
|
2154
|
+
const created = await app.inject({
|
|
2155
|
+
method: 'POST',
|
|
2156
|
+
url: '/api/sessions',
|
|
2157
|
+
payload: { workspaceId: workspace.id, profile: 'default' },
|
|
2158
|
+
});
|
|
2159
|
+
const sessionId = created.json().id;
|
|
2160
|
+
const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
|
|
2161
|
+
expect(handle?.request).toBeDefined();
|
|
2162
|
+
await vi.waitFor(() => {
|
|
2163
|
+
expect(handle?.calls).toContain('thread/read');
|
|
2164
|
+
expect(handle?.calls).toContain('thread/list');
|
|
2165
|
+
});
|
|
2166
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
2167
|
+
state: 'ready',
|
|
2168
|
+
agentActivity: { confidence: 'fresh' },
|
|
2169
|
+
});
|
|
2170
|
+
const reconciliationCalls = handle.calls.filter((method) => method === 'thread/read' || method === 'thread/list');
|
|
2171
|
+
const ordinarySave = await app.inject({
|
|
2172
|
+
method: 'POST',
|
|
2173
|
+
url: `/api/sessions/${sessionId}/model`,
|
|
2174
|
+
payload: { model: 'gpt-5.6-terra' },
|
|
2175
|
+
});
|
|
2176
|
+
expect(ordinarySave.statusCode).toBe(200);
|
|
2177
|
+
expect(handle.calls.filter((method) => method === 'thread/read' || method === 'thread/list')).toEqual(reconciliationCalls);
|
|
2178
|
+
await app.listen({ host: '127.0.0.1', port: 0 });
|
|
2179
|
+
const address = app.server.address();
|
|
2180
|
+
if (!address || typeof address === 'string')
|
|
2181
|
+
throw new Error('Expected TCP listener');
|
|
2182
|
+
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
|
|
2183
|
+
headers: {
|
|
2184
|
+
origin: relyingParty.publicOrigin,
|
|
2185
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
2186
|
+
},
|
|
2187
|
+
});
|
|
2188
|
+
const activityEvents = [];
|
|
2189
|
+
socket.on('message', (data) => activityEvents.push(JSON.parse(String(data))));
|
|
2190
|
+
await once(socket, 'open');
|
|
2191
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
2192
|
+
agentActivity: { confidence: 'fresh' },
|
|
2193
|
+
});
|
|
2194
|
+
activityCallbacks.splice(0);
|
|
2195
|
+
handle.failReads = true;
|
|
2196
|
+
await expect(app.inject({ method: 'POST', url: `/api/sessions/${sessionId}/activity/refresh` })).resolves.toMatchObject({ statusCode: 202 });
|
|
2197
|
+
for (let retry = 0; retry < 3; retry += 1) {
|
|
2198
|
+
await vi.waitFor(() => expect(activityCallbacks.length).toBeGreaterThan(0));
|
|
2199
|
+
const callback = activityCallbacks.shift();
|
|
2200
|
+
expect(callback).toBeDefined();
|
|
2201
|
+
callback();
|
|
2202
|
+
await Promise.resolve();
|
|
2203
|
+
await Promise.resolve();
|
|
2204
|
+
}
|
|
2205
|
+
await vi.waitFor(() => expect(activityDiagnostic).toHaveBeenCalledTimes(1));
|
|
2206
|
+
expect(activityDiagnostic).toHaveBeenCalledWith(sessionId, 'reconcileExhausted');
|
|
2207
|
+
handle.failReads = false;
|
|
2208
|
+
const initialActivityEvents = activityEvents.filter((message) => message.event.type === 'agent.activity.updated').length;
|
|
2209
|
+
handle.notify({
|
|
2210
|
+
method: 'thread/started',
|
|
2211
|
+
params: { thread: { id: 'thread-1', status: { type: 'active' } } },
|
|
2212
|
+
});
|
|
2213
|
+
await vi.waitFor(() => expect(activityEvents.filter((message) => message.event.type === 'agent.activity.updated')).toHaveLength(initialActivityEvents + 1));
|
|
2214
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
2215
|
+
agentActivity: { root: { state: 'working' } },
|
|
2216
|
+
});
|
|
2217
|
+
expect(activityEvents.filter((message) => message.event.type === 'agent.activity.updated')).toHaveLength(initialActivityEvents + 1);
|
|
2218
|
+
const activitySequence = activityEvents.find((message) => message.event.type === 'agent.activity.updated').event.sequence;
|
|
2219
|
+
socket.close();
|
|
2220
|
+
const replaySocket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=${activitySequence - 1}`, {
|
|
2221
|
+
headers: {
|
|
2222
|
+
origin: relyingParty.publicOrigin,
|
|
2223
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
2224
|
+
},
|
|
2225
|
+
});
|
|
2226
|
+
const replayEvents = [];
|
|
2227
|
+
replaySocket.on('message', (data) => replayEvents.push(JSON.parse(String(data))));
|
|
2228
|
+
await once(replaySocket, 'open');
|
|
2229
|
+
await vi.waitFor(() => expect(replayEvents.some((message) => message.event.sequence === activitySequence)).toBe(true));
|
|
2230
|
+
expect(replayEvents.filter((message) => message.event.sequence === activitySequence)).toHaveLength(1);
|
|
2231
|
+
replaySocket.close();
|
|
2232
|
+
// Replaying the same app-server fact is semantically idempotent: the
|
|
2233
|
+
// session snapshot remains stable and no prompt/collaboration text enters it.
|
|
2234
|
+
handle.notify({
|
|
2235
|
+
method: 'thread/started',
|
|
2236
|
+
params: { thread: { id: 'thread-1', status: { type: 'active' } } },
|
|
2237
|
+
});
|
|
2238
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
2239
|
+
agentActivity: { root: { state: 'working' }, subagents: [] },
|
|
2240
|
+
});
|
|
2241
|
+
const pending = handle.request({
|
|
2242
|
+
id: 7,
|
|
2243
|
+
method: 'item/tool/requestUserInput',
|
|
2244
|
+
params: { isBlocking: true, questions: [] },
|
|
2245
|
+
});
|
|
2246
|
+
const cleared = pending.catch((error) => error);
|
|
2247
|
+
expect((await app.inject(`/api/sessions/${sessionId}/history`)).json().interactions).toEqual([
|
|
2248
|
+
expect.objectContaining({ requestId: '7', resolvedAt: null }),
|
|
2249
|
+
]);
|
|
2250
|
+
handle.notify({
|
|
2251
|
+
method: 'serverRequest/resolved',
|
|
2252
|
+
params: { threadId: 'thread-1', requestId: 7 },
|
|
2253
|
+
});
|
|
2254
|
+
expect(await cleared).toEqual(expect.objectContaining({ message: 'CODEX_SERVER_REQUEST_CLEARED' }));
|
|
2255
|
+
expect((await app.inject(`/api/sessions/${sessionId}/history`)).json().interactions).toEqual([
|
|
2256
|
+
expect.objectContaining({
|
|
2257
|
+
requestId: '7',
|
|
2258
|
+
resolvedAt: expect.any(String),
|
|
2259
|
+
outcome: 'dismissed',
|
|
2260
|
+
}),
|
|
2261
|
+
]);
|
|
2262
|
+
const attention = handle.request({
|
|
2263
|
+
id: 8,
|
|
2264
|
+
method: 'item/tool/call',
|
|
2265
|
+
params: {
|
|
2266
|
+
tool: 'gestalt_org_plan_attention',
|
|
2267
|
+
arguments: {
|
|
2268
|
+
reason: 'permissionRequired',
|
|
2269
|
+
summary: 'A protected release needs approval.',
|
|
2270
|
+
requestedAction: 'Grant the release permission.',
|
|
2271
|
+
resumeCondition: 'permissionGranted',
|
|
2272
|
+
},
|
|
2273
|
+
},
|
|
2274
|
+
});
|
|
2275
|
+
await vi.waitFor(async () => {
|
|
2276
|
+
expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
2277
|
+
pendingInteractions: [
|
|
2278
|
+
expect.objectContaining({
|
|
2279
|
+
requestId: '8',
|
|
2280
|
+
kind: 'orgPlanAttention',
|
|
2281
|
+
payload: expect.objectContaining({ reason: 'permissionRequired' }),
|
|
2282
|
+
}),
|
|
2283
|
+
],
|
|
2284
|
+
agentActivity: { root: { state: 'awaitingHuman', reason: 'permissionRequired' } },
|
|
2285
|
+
});
|
|
2286
|
+
});
|
|
2287
|
+
const auditSocket = new WebSocket(`ws://127.0.0.1:${address.port}/api/sessions/${sessionId}/events?after=0`, {
|
|
2288
|
+
headers: {
|
|
2289
|
+
origin: relyingParty.publicOrigin,
|
|
2290
|
+
cookie: 'gestalt_mobile_session=test-session',
|
|
2291
|
+
},
|
|
2292
|
+
});
|
|
2293
|
+
const auditEvents = [];
|
|
2294
|
+
auditSocket.on('message', (data) => auditEvents.push(JSON.parse(String(data))));
|
|
2295
|
+
await once(auditSocket, 'open');
|
|
2296
|
+
await vi.waitFor(() => expect(auditEvents.some((message) => message.event.type === 'org-plan.attention-required')).toBe(true));
|
|
2297
|
+
expect((await app.inject(`/api/sessions/${sessionId}/attention`)).json()).toMatchObject({
|
|
2298
|
+
requestId: '8',
|
|
2299
|
+
attention: { reason: 'permissionRequired', resumeCondition: 'permissionGranted' },
|
|
2300
|
+
});
|
|
2301
|
+
const attentionResponse = toOrgPlanAttentionToolResponse({
|
|
2302
|
+
action: 'resume',
|
|
2303
|
+
guidance: 'The release permission is now granted.',
|
|
2304
|
+
});
|
|
2305
|
+
expect((await app.inject({
|
|
2306
|
+
method: 'POST',
|
|
2307
|
+
url: `/api/sessions/${sessionId}/interactions/8`,
|
|
2308
|
+
payload: attentionResponse,
|
|
2309
|
+
})).statusCode).toBe(400);
|
|
2310
|
+
expect(await app.inject({
|
|
2311
|
+
method: 'POST',
|
|
2312
|
+
url: `/api/sessions/${sessionId}/attention/8/resolve`,
|
|
2313
|
+
payload: {
|
|
2314
|
+
operationKey: 'attention-8',
|
|
2315
|
+
action: 'resume',
|
|
2316
|
+
guidance: 'The release permission is now granted.',
|
|
2317
|
+
},
|
|
2318
|
+
})).toMatchObject({ statusCode: 202 });
|
|
2319
|
+
expect(await attention).toEqual(attentionResponse);
|
|
2320
|
+
expect((await app.inject({
|
|
2321
|
+
method: 'POST',
|
|
2322
|
+
url: `/api/sessions/${sessionId}/attention/8/resolve`,
|
|
2323
|
+
payload: { operationKey: 'attention-8', action: 'resume' },
|
|
2324
|
+
})).json()).toMatchObject({ accepted: true, replayed: true });
|
|
2325
|
+
expect((await app.inject({
|
|
2326
|
+
method: 'POST',
|
|
2327
|
+
url: `/api/sessions/${sessionId}/attention/8/resolve`,
|
|
2328
|
+
payload: { operationKey: 'other-key', action: 'resume' },
|
|
2329
|
+
})).json()).toEqual({ code: 'ATTENTION_OPERATION_STALE' });
|
|
2330
|
+
await vi.waitFor(() => expect(auditEvents.some((message) => message.event.type === 'org-plan.attention-resolved')).toBe(true));
|
|
2331
|
+
const requiredIndex = auditEvents.findIndex((message) => message.event.type === 'org-plan.attention-required');
|
|
2332
|
+
const resolvedIndex = auditEvents.findIndex((message) => message.event.type === 'org-plan.attention-resolved');
|
|
2333
|
+
expect(requiredIndex).toBeGreaterThanOrEqual(0);
|
|
2334
|
+
expect(resolvedIndex).toBeGreaterThan(requiredIndex);
|
|
2335
|
+
auditSocket.close();
|
|
2336
|
+
const attentionHistory = (await app.inject(`/api/sessions/${sessionId}/history`)).json();
|
|
2337
|
+
expect(attentionHistory.interactions).toEqual(expect.arrayContaining([
|
|
2338
|
+
expect.objectContaining({
|
|
2339
|
+
requestId: '8',
|
|
2340
|
+
kind: 'orgPlanAttention',
|
|
2341
|
+
outcome: 'answered',
|
|
2342
|
+
}),
|
|
2343
|
+
]));
|
|
2344
|
+
expect(JSON.stringify(attentionHistory)).not.toContain('The release permission is now granted.');
|
|
2345
|
+
// A server-cleared attention request is a durable failed audit and must
|
|
2346
|
+
// re-project the root rather than leaving the GUI awaiting a vanished alert.
|
|
2347
|
+
const clearedAttention = handle.request({
|
|
2348
|
+
id: 9,
|
|
2349
|
+
method: 'item/tool/call',
|
|
2350
|
+
params: {
|
|
2351
|
+
tool: 'gestalt_org_plan_attention',
|
|
2352
|
+
arguments: {
|
|
2353
|
+
reason: 'externalState',
|
|
2354
|
+
summary: 'The remote state changed.',
|
|
2355
|
+
requestedAction: 'Refresh the remote state.',
|
|
2356
|
+
resumeCondition: 'externalStateChanged',
|
|
2357
|
+
},
|
|
2358
|
+
},
|
|
2359
|
+
});
|
|
2360
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
2361
|
+
agentActivity: { root: { state: 'awaitingHuman' } },
|
|
2362
|
+
}));
|
|
2363
|
+
handle.notify({
|
|
2364
|
+
method: 'serverRequest/resolved',
|
|
2365
|
+
params: { threadId: 'thread-1', requestId: 9 },
|
|
2366
|
+
});
|
|
2367
|
+
await expect(clearedAttention).rejects.toMatchObject({
|
|
2368
|
+
message: 'CODEX_SERVER_REQUEST_CLEARED',
|
|
2369
|
+
});
|
|
2370
|
+
await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
|
|
2371
|
+
agentActivity: { root: { state: 'working' } },
|
|
2372
|
+
}));
|
|
2373
|
+
expect((await app.inject(`/api/sessions/${sessionId}/history`)).json().interactions).toEqual(expect.arrayContaining([
|
|
2374
|
+
expect.objectContaining({ requestId: '9', kind: 'orgPlanAttention', outcome: 'failed' }),
|
|
2375
|
+
]));
|
|
2376
|
+
await app.close();
|
|
2377
|
+
});
|
|
2378
|
+
});
|
|
2379
|
+
});
|