gestalt-mobile 0.41.1 → 0.41.3

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.
@@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later
16
16
  <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
17
  <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
18
  <title>Gestalt Mobile</title>
19
- <script type="module" crossorigin src="/assets/index-G2PznC5F.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BRly-q56.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-D57mr3B1.css">
21
21
  </head>
22
22
  <body>
@@ -12,14 +12,14 @@ import { join } from 'node:path';
12
12
  import { describe, expect, it, onTestFinished, vi } from 'vitest';
13
13
  import WebSocket from 'ws';
14
14
  import { composeRelayApp } from './composition.js';
15
- import { AUTOPILOT_CONTINUATION_PROMPT } from './features/autopilot/application/policy.js';
15
+ import { autopilotExecutorLaunchPrompt } from './features/autopilot/application/policy.js';
16
16
  import { CodexJsonRpcError } from './platform/codex/json-rpc-client.js';
17
17
  import { SqliteAuthorizationStore } from './platform/auth/sqlite-authorization-store.js';
18
18
  import { authorizationSessionId, authorizedDeviceId, localOwnerId, webAuthnCredentialId, } from './features/auth/domain/identifiers.js';
19
19
  import { deviceNickname } from './features/auth/domain/device-nickname.js';
20
20
  import { workspaceId } from './platform/catalog/workspace-id.js';
21
21
  import { planStatusDirectoryPath, planStatusFilePath, } from './platform/plans/filesystem-plan-status-source.js';
22
- import { toOrgPlanAttentionToolResponse } from '../shared/contracts/org-plan-attention.js';
22
+ import { toOrgPlanAttentionAcknowledgement, toOrgPlanAttentionToolResponse, } from '../shared/contracts/org-plan-attention.js';
23
23
  import { toOrgPlanCheckpointToolResponse } from '../shared/contracts/org-plan-checkpoint.js';
24
24
  import { applyProjectionEvent, createChatProjection, } from '../client/features/chat/chat-projection.js';
25
25
  function fakeAppServer(calls) {
@@ -153,6 +153,25 @@ const autopilotPlanText = (childState = 'TODO') => `#+TITLE: Autopilot fixture
153
153
  const completedAutopilotPlanText = () => autopilotPlanText('DONE')
154
154
  .replace('* WIP [#A] Parent', '* DONE [#A] Parent')
155
155
  .replace(':REVIEW_STATUS: UNREVIEWED', ':REVIEW_STATUS: REVIEWED');
156
+ const acceptedL1WithNextTodoPlanText = () => `${completedAutopilotPlanText()}
157
+ * TODO [#B] Next parent
158
+ :PROPERTIES:
159
+ :ID: next-parent
160
+ :SKILLS: $gestalt:org-plan
161
+ :REVIEW_STATUS: UNREVIEWED
162
+ :END:
163
+ - Effort :: Small
164
+ - Goal :: Continue on the next root turn.
165
+ - Notes :: Must remain pending at the accepted-L1 boundary.
166
+ ** TODO [#B] Next child
167
+ :PROPERTIES:
168
+ :ID: next-child
169
+ :END:
170
+ - Why :: Verify boundary separation.
171
+ - Change :: Start only after durable handoff.
172
+ - Tests :: Exercise the next continuation.
173
+ - Done when :: The next L1 starts exactly once.
174
+ `;
156
175
  async function installAutopilotPlan(app, sessionId, workspacePath, name, childState = 'TODO', reason = 'supervision-start') {
157
176
  const planPath = join(workspacePath, `${name}.org`);
158
177
  await writeFile(planPath, autopilotPlanText(childState));
@@ -478,7 +497,12 @@ describe('production composition', () => {
478
497
  input: [
479
498
  {
480
499
  type: 'text',
481
- text: `${AUTOPILOT_CONTINUATION_PROMPT} Launch task_name l1 for canonical L1; retain the canonical label in status and review output.`,
500
+ text: autopilotExecutorLaunchPrompt({
501
+ canonicalTaskName: 'l1',
502
+ canonicalPosition: 'L1',
503
+ generation: 1,
504
+ taskName: 'l1',
505
+ }),
482
506
  text_elements: [],
483
507
  },
484
508
  ],
@@ -958,6 +982,83 @@ describe('production composition', () => {
958
982
  expect(checkpoint.count).toBe(1);
959
983
  await fixture.app.close();
960
984
  });
985
+ it('records and idempotently replays accepted L1 after REVIEWED advances to the next TODO L1', async () => {
986
+ const timers = [];
987
+ const fixture = await createProductionAutopilotFixture({
988
+ autopilotSchedule: (callback) => {
989
+ const timer = { callback, cancelled: false, fired: false };
990
+ timers.push(timer);
991
+ return () => {
992
+ timer.cancelled = true;
993
+ };
994
+ },
995
+ });
996
+ const initial = timers.find((timer) => !timer.cancelled && !timer.fired);
997
+ initial.fired = true;
998
+ initial.callback();
999
+ await vi.waitFor(() => expect(fixture.handles.flatMap((candidate) => candidate.requests.filter((request) => request.method === 'turn/start'))).toHaveLength(1));
1000
+ const handle = fixture.handles.find((candidate) => candidate.request);
1001
+ const session = (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json();
1002
+ const threadId = session.threadId;
1003
+ const turnId = session.activeTurnId;
1004
+ await writeFile(join(fixture.workspacePath, 'autopilot.org'), acceptedL1WithNextTodoPlanText());
1005
+ await expect
1006
+ .poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}/plan`)).json()
1007
+ .currentStepId)
1008
+ .toBe('next-parent');
1009
+ handle.deferResponseSettlement = true;
1010
+ const checkpointCall = (id) => ({
1011
+ id,
1012
+ method: 'item/tool/call',
1013
+ params: {
1014
+ threadId,
1015
+ turnId,
1016
+ tool: 'gestalt_org_plan_checkpoint',
1017
+ arguments: { kind: 'l1Accepted' },
1018
+ },
1019
+ });
1020
+ const startsBefore = fixture.handles
1021
+ .flatMap((candidate) => candidate.requests)
1022
+ .filter((request) => request.method === 'turn/start').length;
1023
+ const first = handle.request(checkpointCall(813));
1024
+ await vi.waitFor(() => expect(handle.pendingResponses.has(813)).toBe(true));
1025
+ handle.responseSettled({ id: 813, outcome: 'resultWritten' });
1026
+ await expect(first).resolves.toEqual(toOrgPlanCheckpointToolResponse('recorded'));
1027
+ const replay = handle.request(checkpointCall(814));
1028
+ await vi.waitFor(() => expect(handle.pendingResponses.has(814)).toBe(true));
1029
+ handle.responseSettled({ id: 814, outcome: 'resultWritten' });
1030
+ await expect(replay).resolves.toEqual(toOrgPlanCheckpointToolResponse('alreadyRecorded'));
1031
+ const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
1032
+ const acceptedEvents = database
1033
+ .prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'org-plan.milestone-checkpointed'")
1034
+ .get(fixture.sessionId);
1035
+ const pendingInteractions = database
1036
+ .prepare('SELECT count(*) AS count FROM pending_interactions WHERE session_id = ? AND resolved_at IS NULL')
1037
+ .get(fixture.sessionId);
1038
+ database.close();
1039
+ expect(acceptedEvents.count).toBe(1);
1040
+ expect(pendingInteractions.count).toBe(0);
1041
+ expect(fixture.handles
1042
+ .flatMap((candidate) => candidate.requests)
1043
+ .filter((request) => request.method === 'turn/start')).toHaveLength(startsBefore);
1044
+ handle.notify({ method: 'turn/completed', params: { threadId, turn: { id: turnId } } });
1045
+ await expect
1046
+ .poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json().activeTurnId)
1047
+ .toBeNull();
1048
+ const startedTurns = () => fixture.handles
1049
+ .flatMap((candidate) => candidate.requests)
1050
+ .filter((request) => request.method === 'turn/start').length;
1051
+ for (let attempts = 0; attempts < 3 && startedTurns() === startsBefore; attempts += 1) {
1052
+ await vi.waitFor(() => expect(timers.find((timer) => !timer.cancelled && !timer.fired)).toBeDefined());
1053
+ const continuation = timers.find((timer) => !timer.cancelled && !timer.fired);
1054
+ continuation.fired = true;
1055
+ continuation.callback();
1056
+ await vi.waitFor(() => expect(startedTurns() > startsBefore ||
1057
+ timers.some((timer) => !timer.cancelled && !timer.fired)).toBe(true));
1058
+ }
1059
+ await vi.waitFor(() => expect(startedTurns()).toBe(startsBefore + 1));
1060
+ await fixture.app.close();
1061
+ });
961
1062
  it('returns a typed failed checkpoint result when persistence never occurs', async () => {
962
1063
  let coordinator;
963
1064
  const timers = [];
@@ -980,7 +1081,13 @@ describe('production composition', () => {
980
1081
  const { handle, response, startsBefore, writersBefore } = await holdCheckpointRequest(fixture, timers, 995, false);
981
1082
  await vi.waitFor(() => expect(handle.pendingResponses.has(995)).toBe(true));
982
1083
  handle.responseSettled({ id: 995, outcome: 'resultWritten' });
983
- await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse('failed'));
1084
+ const failure = (await response);
1085
+ expect(failure.success).toBe(false);
1086
+ expect(JSON.parse(failure.contentItems[0].text)).toMatchObject({
1087
+ status: 'failed',
1088
+ reasonCode: 'checkpointPersistenceFailed',
1089
+ correlationId: expect.any(String),
1090
+ });
984
1091
  expect(coordinator.checkpointAccepted).not.toBe(original);
985
1092
  expect(fixture.handles).toHaveLength(writersBefore);
986
1093
  expect(fixture.handles
@@ -1340,14 +1447,20 @@ describe('production composition', () => {
1340
1447
  const planIdentity = createHash('sha256')
1341
1448
  .update(join(fixture.workspacePath, 'autopilot.org'))
1342
1449
  .digest('hex');
1343
- await expect(handle.request(l2CheckpointCall(812, threadId, active.active_turn_id, planIdentity))).resolves.toEqual(toOrgPlanCheckpointToolResponse('failed'));
1450
+ const rejected = (await handle.request(l2CheckpointCall(812, threadId, active.active_turn_id, planIdentity)));
1451
+ expect(rejected.success).toBe(false);
1452
+ expect(JSON.parse(rejected.contentItems[0].text)).toMatchObject({
1453
+ status: 'failed',
1454
+ reasonCode: 'rootNotOwner',
1455
+ correlationId: expect.any(String),
1456
+ });
1344
1457
  expect(timers).toHaveLength(timersAfterFinal);
1345
1458
  expect(fixture.handles
1346
1459
  .flatMap((candidate) => candidate.requests)
1347
1460
  .filter((request) => request.method === 'turn/start')).toHaveLength(startsAfterFinal);
1348
1461
  await fixture.app.close();
1349
1462
  });
1350
- it('production attention request requires explicit re-enable and a complete plan transitions to completed', async () => {
1463
+ it('production attention approval resumes Autopilot and a complete plan transitions to completed', async () => {
1351
1464
  const fixture = await createProductionAutopilotFixture();
1352
1465
  const handle = fixture.handles.find((candidate) => candidate.request);
1353
1466
  expect((await fixture.app.inject({
@@ -1356,6 +1469,7 @@ describe('production composition', () => {
1356
1469
  payload: { enabled: true },
1357
1470
  })).statusCode).toBe(200);
1358
1471
  const attention = handle.request(attentionCall(810));
1472
+ await expect(attention).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
1359
1473
  await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
1360
1474
  autopilot: { state: 'attentionRequired', enabled: false, reason: 'attentionRequired' },
1361
1475
  }));
@@ -1365,16 +1479,6 @@ describe('production composition', () => {
1365
1479
  url: `/api/sessions/${fixture.sessionId}/attention/810/resolve`,
1366
1480
  payload: { operationKey: 'resume-attention', action: 'resume' },
1367
1481
  })).statusCode).toBe(202);
1368
- await expect(attention).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
1369
- await new Promise((resolve) => setTimeout(resolve, 25));
1370
- expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
1371
- autopilot: { state: 'attentionRequired', enabled: false },
1372
- });
1373
- expect((await fixture.app.inject({
1374
- method: 'PUT',
1375
- url: `/api/sessions/${fixture.sessionId}/autopilot`,
1376
- payload: { enabled: true },
1377
- })).statusCode).toBe(200);
1378
1482
  await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
1379
1483
  autopilot: { enabled: true, state: expect.stringMatching(/monitoring|backoff/) },
1380
1484
  }));
@@ -1662,7 +1766,7 @@ describe('production composition', () => {
1662
1766
  // rejected child continuation; it must never target the dead child.
1663
1767
  expect((starts[3]?.params).threadId).not.toBe('child-1');
1664
1768
  expect((starts[3]?.params).input?.[0]?.text).toContain('Launch task_name l1_g2 for canonical L1');
1665
- expect((starts[3]?.params).input?.[0]?.text).toContain('explicit model selected by the supervisor');
1769
+ expect((starts[3]?.params).input?.[0]?.text).toContain('agent_type org-plan-executor and reasoning_effort high');
1666
1770
  // Capacity recovery is allowed only for the active root control. It
1667
1771
  // recycles that writer but must retain the one durable g2 handoff.
1668
1772
  const rootHandle = fixture.handles.find((candidate) => candidate.requests.some((request) => request.method === 'turn/start' &&
@@ -1959,6 +2063,56 @@ describe('production composition', () => {
1959
2063
  });
1960
2064
  });
1961
2065
  describeCompositionConcern('attention', () => {
2066
+ it('bounds a wedged attention acknowledgement writer without losing durable attention', async () => {
2067
+ const fixture = await createProductionAutopilotFixture({
2068
+ attentionAcknowledgementDeadlineMs: 100,
2069
+ });
2070
+ const handle = fixture.handles.find((candidate) => candidate.request);
2071
+ handle.deferResponseSettlement = true;
2072
+ const response = handle.request(attentionCall(699));
2073
+ const rejected = expect(response).rejects.toThrow('CODEX_SERVER_REQUEST_CANCELLED');
2074
+ await vi.waitFor(() => expect(handle.pendingResponses.has(699)).toBe(true));
2075
+ await rejected;
2076
+ expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}/attention`)).json()).toMatchObject({ requestId: '699', attention: { reason: 'hardBlock' } });
2077
+ await fixture.app.close();
2078
+ });
2079
+ it('returns a bounded failure when durable attention persistence fails', async () => {
2080
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
2081
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
2082
+ ownTemporaryPaths(root, dataDir);
2083
+ await mkdir(join(root, 'workspace'));
2084
+ const handles = [];
2085
+ const app = await composeAuthorizedApp({
2086
+ root,
2087
+ dataDir,
2088
+ relyingParty,
2089
+ installedCodexVersion: 'codex-cli 0.144.3',
2090
+ startAppServers: true,
2091
+ launchAppServer: liveAppServer(handles),
2092
+ profiles: {
2093
+ list: async () => [],
2094
+ require: async () => ({
2095
+ name: 'default',
2096
+ state: 'ok',
2097
+ status: 'ready',
2098
+ }),
2099
+ },
2100
+ });
2101
+ await createComposedSession(app);
2102
+ const handle = await vi.waitFor(() => {
2103
+ const candidate = handles.find((item) => item.request);
2104
+ expect(candidate).toBeDefined();
2105
+ return candidate;
2106
+ });
2107
+ const database = new DatabaseSync(join(dataDir, 'relay.sqlite'));
2108
+ database.exec(`CREATE TRIGGER reject_attention_insert
2109
+ BEFORE INSERT ON pending_interactions
2110
+ WHEN NEW.kind = 'orgPlanAttention'
2111
+ BEGIN SELECT RAISE(ABORT, 'injected attention persistence failure'); END`);
2112
+ database.close();
2113
+ await expect(handle.request(attentionCall(700))).resolves.toEqual(toOrgPlanAttentionAcknowledgement({ accepted: false, reason: 'persistenceFailed' }));
2114
+ await app.close();
2115
+ });
1962
2116
  it('publishes isolated typed required, resolved, and failed attention transitions through the feature-only seam', async () => {
1963
2117
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
1964
2118
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
@@ -2004,6 +2158,7 @@ describe('production composition', () => {
2004
2158
  const unsubscribeA = transitions.subscribe(sessionA, (event) => receivedA.push(event));
2005
2159
  const unsubscribeB = transitions.subscribe(sessionB, (event) => receivedB.push(event));
2006
2160
  const resolving = handleA.request(attentionCall(701));
2161
+ await expect(resolving).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2007
2162
  await vi.waitFor(() => expect(receivedA).toEqual([
2008
2163
  expect.objectContaining({ kind: 'required', requestId: '701' }),
2009
2164
  ]));
@@ -2013,12 +2168,12 @@ describe('production composition', () => {
2013
2168
  url: `/api/sessions/${sessionA}/attention/701/resolve`,
2014
2169
  payload: { operationKey: 'resolve-a', action: 'resume' },
2015
2170
  })).statusCode).toBe(202);
2016
- await expect(resolving).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
2017
2171
  await vi.waitFor(() => expect(receivedA).toEqual([
2018
2172
  expect.objectContaining({ kind: 'required', requestId: '701' }),
2019
2173
  expect.objectContaining({ kind: 'resolved', requestId: '701' }),
2020
2174
  ]));
2021
2175
  const failing = handleB.request(attentionCall(702, 'permissionRequired'));
2176
+ await expect(failing).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2022
2177
  await vi.waitFor(() => expect(receivedB).toEqual([
2023
2178
  expect.objectContaining({ kind: 'required', requestId: '702' }),
2024
2179
  ]));
@@ -2026,13 +2181,10 @@ describe('production composition', () => {
2026
2181
  method: 'serverRequest/resolved',
2027
2182
  params: { threadId: 'thread-ignored', requestId: 702 },
2028
2183
  });
2029
- await expect(failing).rejects.toMatchObject({ message: 'CODEX_SERVER_REQUEST_CLEARED' });
2030
- await vi.waitFor(() => expect(receivedB).toEqual([
2031
- expect.objectContaining({ kind: 'required', requestId: '702' }),
2032
- expect.objectContaining({ kind: 'failed', requestId: '702' }),
2033
- ]));
2184
+ expect(receivedB).toEqual([expect.objectContaining({ kind: 'required', requestId: '702' })]);
2034
2185
  unsubscribeA();
2035
2186
  const afterUnsubscribe = handleA.request(attentionCall(703));
2187
+ await expect(afterUnsubscribe).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2036
2188
  await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionA}/attention`)).json()).toMatchObject({
2037
2189
  requestId: '703',
2038
2190
  }));
@@ -2041,9 +2193,6 @@ describe('production composition', () => {
2041
2193
  method: 'serverRequest/resolved',
2042
2194
  params: { threadId: 'thread-ignored', requestId: 703 },
2043
2195
  });
2044
- await expect(afterUnsubscribe).rejects.toMatchObject({
2045
- message: 'CODEX_SERVER_REQUEST_CLEARED',
2046
- });
2047
2196
  expect(receivedA).toHaveLength(2);
2048
2197
  unsubscribeB();
2049
2198
  await app.close();
@@ -2074,6 +2223,7 @@ describe('production composition', () => {
2074
2223
  await vi.waitFor(() => expect(handles.find((handle) => handle.request)?.request).toBeDefined());
2075
2224
  const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
2076
2225
  const attentionFirst = handle.request(attentionCall(711, 'permissionRequired'));
2226
+ await expect(attentionFirst).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2077
2227
  const inputFirst = handle.request({
2078
2228
  id: 712,
2079
2229
  method: 'item/tool/requestUserInput',
@@ -2096,11 +2246,11 @@ describe('production composition', () => {
2096
2246
  url: `/api/sessions/${sessionId}/attention/711/resolve`,
2097
2247
  payload: { operationKey: 'attention-first', action: 'resume' },
2098
2248
  });
2099
- await expect(attentionFirst).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
2100
2249
  await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
2101
2250
  agentActivity: { root: { state: 'working' } },
2102
2251
  }));
2103
2252
  const attentionSecond = handle.request(attentionCall(713));
2253
+ await expect(attentionSecond).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2104
2254
  const inputSecond = handle.request({
2105
2255
  id: 714,
2106
2256
  method: 'item/tool/requestUserInput',
@@ -2111,7 +2261,6 @@ describe('production composition', () => {
2111
2261
  url: `/api/sessions/${sessionId}/attention/713/resolve`,
2112
2262
  payload: { operationKey: 'attention-second', action: 'resume' },
2113
2263
  });
2114
- await expect(attentionSecond).resolves.toEqual(toOrgPlanAttentionToolResponse({ action: 'resume' }));
2115
2264
  await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
2116
2265
  agentActivity: { root: { state: 'awaitingHuman', reason: 'pendingInteraction' } },
2117
2266
  }));
@@ -2150,10 +2299,8 @@ describe('production composition', () => {
2150
2299
  const handle = handles.find((candidate) => candidate.calls.includes('thread/start'));
2151
2300
  const active = handle.request(attentionCall(801));
2152
2301
  const terminal = handle.request(attentionCall(802));
2153
- const terminalResponse = toOrgPlanAttentionToolResponse({
2154
- action: 'resume',
2155
- guidance: 'Sensitive terminal guidance.',
2156
- });
2302
+ await expect(active).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2303
+ await expect(terminal).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2157
2304
  const accepted = await first.inject({
2158
2305
  method: 'POST',
2159
2306
  url: `/api/sessions/${sessionId}/attention/802/resolve`,
@@ -2164,9 +2311,7 @@ describe('production composition', () => {
2164
2311
  },
2165
2312
  });
2166
2313
  const resolvedAt = accepted.json().resolvedAt;
2167
- await expect(terminal).resolves.toEqual(terminalResponse);
2168
2314
  await first.close();
2169
- await expect(active).rejects.toMatchObject({ message: 'CODEX_SERVER_REQUEST_CANCELLED' });
2170
2315
  const reopened = await composeAuthorizedApp({
2171
2316
  root,
2172
2317
  dataDir,
@@ -2205,7 +2350,7 @@ describe('production composition', () => {
2205
2350
  })).json()).toEqual({ code: 'ATTENTION_OPERATION_STALE' });
2206
2351
  await reopened.close();
2207
2352
  });
2208
- it('reports supported offline writers separately from legacy sessions without the attention capability', async () => {
2353
+ it('resolves acknowledged attention offline while retaining legacy writer diagnostics', async () => {
2209
2354
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
2210
2355
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
2211
2356
  ownTemporaryPaths(root, dataDir);
@@ -2230,18 +2375,17 @@ describe('production composition', () => {
2230
2375
  const started = handles.filter((handle) => handle.calls.includes('thread/start'));
2231
2376
  const supportedRequest = started[0].request(attentionCall(901));
2232
2377
  const legacyRequest = started[1].request(attentionCall(902));
2378
+ await expect(supportedRequest).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2379
+ await expect(legacyRequest).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
2233
2380
  await vi.waitFor(async () => expect((await first.inject(`/api/sessions/${legacySession}/attention`)).statusCode).toBe(200));
2234
2381
  await first.close();
2235
- await expect(supportedRequest).rejects.toMatchObject({
2236
- message: 'CODEX_SERVER_REQUEST_CANCELLED',
2237
- });
2238
- await expect(legacyRequest).rejects.toMatchObject({
2239
- message: 'CODEX_SERVER_REQUEST_CANCELLED',
2240
- });
2241
2382
  const database = new DatabaseSync(join(dataDir, 'relay.sqlite'));
2242
2383
  database
2243
2384
  .prepare('UPDATE relay_sessions SET attention_tool_capability = NULL WHERE id = ?')
2244
2385
  .run(legacySession);
2386
+ database
2387
+ .prepare("UPDATE pending_interactions SET resolution_state = 'active' WHERE session_id = ? AND request_id = '902'")
2388
+ .run(legacySession);
2245
2389
  database.close();
2246
2390
  const offline = await composeAuthorizedApp({
2247
2391
  root,
@@ -2254,7 +2398,7 @@ describe('production composition', () => {
2254
2398
  method: 'POST',
2255
2399
  url: `/api/sessions/${supportedSession}/attention/901/resolve`,
2256
2400
  payload: { operationKey: 'offline-supported', action: 'resume' },
2257
- })).json()).toEqual({ code: 'ATTENTION_WRITER_UNAVAILABLE' });
2401
+ })).statusCode).toBe(202);
2258
2402
  expect((await offline.inject({
2259
2403
  method: 'POST',
2260
2404
  url: `/api/sessions/${legacySession}/attention/902/resolve`,
@@ -3703,6 +3847,7 @@ describe('production composition', () => {
3703
3847
  },
3704
3848
  },
3705
3849
  });
3850
+ await expect(attention).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
3706
3851
  await vi.waitFor(async () => {
3707
3852
  expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
3708
3853
  pendingInteractions: [
@@ -3747,7 +3892,6 @@ describe('production composition', () => {
3747
3892
  guidance: 'The release permission is now granted.',
3748
3893
  },
3749
3894
  })).toMatchObject({ statusCode: 202 });
3750
- expect(await attention).toEqual(attentionResponse);
3751
3895
  expect((await app.inject({
3752
3896
  method: 'POST',
3753
3897
  url: `/api/sessions/${sessionId}/attention/8/resolve`,
@@ -3773,8 +3917,8 @@ describe('production composition', () => {
3773
3917
  }),
3774
3918
  ]));
3775
3919
  expect(JSON.stringify(attentionHistory)).not.toContain('The release permission is now granted.');
3776
- // A server-cleared attention request is a durable failed audit and must
3777
- // re-project the root rather than leaving the GUI awaiting a vanished alert.
3920
+ // Clearing the already-acknowledged dynamic request must not erase the
3921
+ // durable attention record or wedge the caller.
3778
3922
  const clearedAttention = handle.request({
3779
3923
  id: 9,
3780
3924
  method: 'item/tool/call',
@@ -3788,6 +3932,7 @@ describe('production composition', () => {
3788
3932
  },
3789
3933
  },
3790
3934
  });
3935
+ await expect(clearedAttention).resolves.toEqual(toOrgPlanAttentionAcknowledgement());
3791
3936
  await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
3792
3937
  agentActivity: { root: { state: 'awaitingHuman' } },
3793
3938
  }));
@@ -3795,14 +3940,11 @@ describe('production composition', () => {
3795
3940
  method: 'serverRequest/resolved',
3796
3941
  params: { threadId: 'thread-1', requestId: 9 },
3797
3942
  });
3798
- await expect(clearedAttention).rejects.toMatchObject({
3799
- message: 'CODEX_SERVER_REQUEST_CLEARED',
3800
- });
3801
3943
  await vi.waitFor(async () => expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
3802
- agentActivity: { root: { state: 'working' } },
3944
+ agentActivity: { root: { state: 'awaitingHuman' } },
3803
3945
  }));
3804
3946
  expect((await app.inject(`/api/sessions/${sessionId}/history`)).json().interactions).toEqual(expect.arrayContaining([
3805
- expect.objectContaining({ requestId: '9', kind: 'orgPlanAttention', outcome: 'failed' }),
3947
+ expect.objectContaining({ requestId: '9', kind: 'orgPlanAttention', resolvedAt: null }),
3806
3948
  ]));
3807
3949
  const quiz = handle.request({
3808
3950
  id: 71,