@openchambery/web 1.19.0 → 1.19.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/assets/{AssistantView-WZ8qXOm6.js → AssistantView-DGqRio2e.js} +1 -1
  2. package/dist/assets/{ChatContainer-CPOlhHJn.js → ChatContainer-CM43Pxln.js} +6 -6
  3. package/dist/assets/ContextPanel-Bp3ZKAJE.js +288 -0
  4. package/dist/assets/{DiagramView-BFE0XxUH.js → DiagramView-BH5YxFAL.js} +1 -1
  5. package/dist/assets/{MarkdownRendererImpl-BisXjTdJ.js → MarkdownRendererImpl-Cmm9i5Vj.js} +1 -1
  6. package/dist/assets/{MobileDetailNavigation-DRiFdQXq.js → MobileDetailNavigation-nITHp7W3.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-D-kwfbug.js → MobileShareBridge-C_QaKaB6.js} +1 -1
  8. package/dist/assets/MobileSurface-CjpCviv6.js +2 -0
  9. package/dist/assets/{MultiRunWindow-C_Tbyfco.js → MultiRunWindow-B1ALyR36.js} +1 -1
  10. package/dist/assets/{SettingsView-BOrARuHr.js → SettingsView-BW8d_8pw.js} +1 -1
  11. package/dist/assets/{SettingsWindow-BmegFGj-.js → SettingsWindow-7SmQV5GW.js} +1 -1
  12. package/dist/assets/{TerminalView-BcFMX_mg.js → TerminalView-C7EFF7ZS.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-DN2IYmqU.js → ToolOutputDialog-Cba_60bU.js} +1 -1
  14. package/dist/assets/appThemeRegistry-Co5dwWx_.js +2 -0
  15. package/dist/assets/{gitApi-CxPLKmZ2.js → gitApi-DaTTo7St.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-2Mzsx_VV.js → insertionBoundaries-BoIHXczc.js} +1 -1
  17. package/dist/assets/{main-dGakBRsd.js → main-IyYkGXkX.js} +2 -2
  18. package/dist/assets/{main-C1IdVCQf.js → main-WVLfE2Df.js} +5 -5
  19. package/dist/assets/{miniChat-C8rpOcNP.js → miniChat-CAAQzm9R.js} +2 -2
  20. package/dist/assets/{mobile-Dm22ebC9.js → mobile-BiCmfs5Y.js} +2 -2
  21. package/dist/assets/{multirun-ColPhFw1.js → multirun-DPXG-TWE.js} +1 -1
  22. package/dist/assets/{projectMeta-CKI_msIR.js → projectMeta-B0YA9FxC.js} +1 -1
  23. package/dist/assets/{radio-DKNOd1MW.js → radio-CUea8K8R.js} +1 -1
  24. package/dist/assets/{renderElectronMiniChatApp-B8ly-dZn.js → renderElectronMiniChatApp-CGA16ZyN.js} +2 -2
  25. package/dist/assets/{renderMobileApp-OAlYU54b.js → renderMobileApp-D9fgFt6E.js} +4 -4
  26. package/dist/assets/{runtimeConfig-QFdp6Itq.js → runtimeConfig-BJ3S27Ah.js} +1 -1
  27. package/dist/assets/runtimeEndpointReset-C9pWVmjf.js +4 -0
  28. package/dist/assets/{shikiHighlight-C7KkxQ9i.js → shikiHighlight-_cgskSv2.js} +1 -1
  29. package/dist/assets/{useAppFontEffects-D0FmY4C_.js → useAppFontEffects-B_a7MLYy.js} +5 -5
  30. package/dist/assets/{useDesktopWindowControlsLayout-Ce9FSwxa.js → useDesktopWindowControlsLayout-D-74BaI3.js} +1 -1
  31. package/dist/assets/{useEffectiveDirectory-C5CrdVlr.js → useEffectiveDirectory-DWwrRiBF.js} +1 -1
  32. package/dist/assets/{useMobileNavigationStore-jKG6OU5F.js → useMobileNavigationStore-7LBI6idi.js} +1 -1
  33. package/dist/assets/{useSessionAutoCleanup-C6GD9W2z.js → useSessionAutoCleanup-Buvg8rem.js} +1 -1
  34. package/dist/assets/{useWorkerHighlightedLines-mc9J5erB.js → useWorkerHighlightedLines-BdlVg3Sj.js} +1 -1
  35. package/dist/index.html +2 -2
  36. package/dist/mini-chat.html +2 -2
  37. package/dist/mobile.html +2 -2
  38. package/package.json +1 -1
  39. package/server/index.js +4 -0
  40. package/server/lib/opencode/DOCUMENTATION.md +1 -1
  41. package/server/lib/opencode/routes.js +24 -5
  42. package/server/lib/opencode/routes.test.js +78 -1
  43. package/server/lib/scheduled-tasks/DOCUMENTATION.md +25 -2
  44. package/server/lib/scheduled-tasks/runtime.js +234 -3
  45. package/server/lib/scheduled-tasks/runtime.test.js +221 -0
  46. package/dist/assets/ContextPanel-ClzJl6RQ.js +0 -288
  47. package/dist/assets/MobileSurface-NsB1sKld.js +0 -2
  48. package/dist/assets/appThemeRegistry-7v5TIiNK.js +0 -2
  49. package/dist/assets/runtimeEndpointReset-MzR_-tYR.js +0 -4
@@ -305,6 +305,108 @@ const extractGoalFromSession = (session) => {
305
305
  };
306
306
  };
307
307
 
308
+ /**
309
+ * Single-shot session outcome for post-run continuation (no polling).
310
+ * Goal-enabled: terminal complete → success; blocked/budgetLimited → error;
311
+ * otherwise not-yet-success. Non-goal: busy/retry, assistant error, or
312
+ * completed assistant; incomplete/empty tails are not treated as success.
313
+ */
314
+ const snapshotSessionOutcome = async ({
315
+ client,
316
+ sessionID,
317
+ projectPath,
318
+ goalEnabled,
319
+ signal,
320
+ }) => {
321
+ const requestOptions = signal ? { signal } : undefined;
322
+
323
+ if (goalEnabled && typeof client?.session?.get === 'function') {
324
+ try {
325
+ const sessionResult = await client.session.get({
326
+ sessionID,
327
+ directory: projectPath,
328
+ }, requestOptions);
329
+ if (!sessionResult?.error) {
330
+ const goal = extractGoalFromSession(sessionResult?.data);
331
+ if (goal && GOAL_TERMINAL_STATUSES.has(goal.status)) {
332
+ if (goal.status === 'complete') {
333
+ return { outcome: 'success' };
334
+ }
335
+ return {
336
+ outcome: 'error',
337
+ error: goal.note || `goal ${goal.status}`,
338
+ };
339
+ }
340
+ }
341
+ } catch (error) {
342
+ if (signal?.aborted) throw error;
343
+ }
344
+ return { outcome: 'busy' };
345
+ }
346
+
347
+ if (typeof client?.session?.status === 'function') {
348
+ try {
349
+ const statusResult = await client.session.status({
350
+ directory: projectPath,
351
+ }, requestOptions);
352
+ if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
353
+ const statusValue = statusResult.data[sessionID];
354
+ const type = statusValue?.type ?? statusValue?.status;
355
+ if (type === 'busy' || type === 'retry') {
356
+ return { outcome: 'busy' };
357
+ }
358
+ }
359
+ } catch (error) {
360
+ if (signal?.aborted) throw error;
361
+ }
362
+ }
363
+
364
+ if (typeof client?.session?.messages === 'function') {
365
+ try {
366
+ const messagesResult = await client.session.messages({
367
+ sessionID,
368
+ directory: projectPath,
369
+ limit: 50,
370
+ }, requestOptions);
371
+ if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
372
+ const lastInfo = readMessageInfo(messagesResult.data.at(-1));
373
+ if (lastInfo?.role === 'assistant') {
374
+ if (lastInfo.error) {
375
+ return {
376
+ outcome: 'error',
377
+ error: formatAssistantError(lastInfo.error),
378
+ };
379
+ }
380
+ if (lastInfo.time?.completed) {
381
+ return { outcome: 'success' };
382
+ }
383
+ }
384
+ }
385
+ } catch (error) {
386
+ if (signal?.aborted) throw error;
387
+ }
388
+ }
389
+
390
+ return { outcome: 'busy' };
391
+ };
392
+
393
+ const parseSessionEventIdle = (event) => {
394
+ const payload = event?.payload?.payload ?? event?.payload;
395
+ const properties = payload?.properties;
396
+ const sessionID = typeof properties?.sessionID === 'string' ? properties.sessionID : '';
397
+ if (!sessionID) {
398
+ return { idle: false, sessionID: '' };
399
+ }
400
+ if (payload?.type === 'session.idle') {
401
+ return { idle: true, sessionID };
402
+ }
403
+ if (payload?.type === 'session.status') {
404
+ const type = properties?.status?.type ?? properties?.info?.type;
405
+ return { idle: type === 'idle', sessionID };
406
+ }
407
+ return { idle: false, sessionID };
408
+ };
409
+
308
410
  export const createScheduledTasksRuntime = (deps) => {
309
411
  const {
310
412
  projectConfigRuntime,
@@ -331,6 +433,34 @@ export const createScheduledTasksRuntime = (deps) => {
331
433
  const runningCountByProject = new Map();
332
434
  let runningGlobalCount = 0;
333
435
  const queue = [];
436
+ /** sessionID → { projectID, taskID } for post-run continuation correction. */
437
+ const lastSessionOwners = new Map();
438
+
439
+ const rememberLastSession = (projectID, task) => {
440
+ const sessionID = task?.state?.lastSessionId;
441
+ if (typeof sessionID !== 'string' || !sessionID || !task?.id) {
442
+ return;
443
+ }
444
+ lastSessionOwners.set(sessionID, { projectID, taskID: task.id });
445
+ };
446
+
447
+ const forgetLastSessionsForProject = (projectID) => {
448
+ for (const [sessionID, owner] of [...lastSessionOwners.entries()]) {
449
+ if (owner.projectID === projectID) {
450
+ lastSessionOwners.delete(sessionID);
451
+ }
452
+ }
453
+ };
454
+
455
+ const forgetLastSessionIfOwned = (projectID, taskID, sessionID) => {
456
+ if (typeof sessionID !== 'string' || !sessionID) {
457
+ return;
458
+ }
459
+ const owner = lastSessionOwners.get(sessionID);
460
+ if (owner?.projectID === projectID && owner?.taskID === taskID) {
461
+ lastSessionOwners.delete(sessionID);
462
+ }
463
+ };
334
464
 
335
465
  const clearTimerForKey = (taskKey) => {
336
466
  const timer = timersByTaskKey.get(taskKey);
@@ -361,9 +491,11 @@ export const createScheduledTasksRuntime = (deps) => {
361
491
 
362
492
  const setProjectTasks = (projectID, tasks) => {
363
493
  clearProjectTimers(projectID);
494
+ forgetLastSessionsForProject(projectID);
364
495
  const taskMap = new Map();
365
496
  for (const task of tasks) {
366
497
  taskMap.set(task.id, task);
498
+ rememberLastSession(projectID, task);
367
499
  }
368
500
  tasksByProject.set(projectID, taskMap);
369
501
  };
@@ -412,7 +544,14 @@ export const createScheduledTasksRuntime = (deps) => {
412
544
  if (!taskMap) {
413
545
  return;
414
546
  }
547
+ const previous = taskMap.get(nextTask.id);
548
+ const previousSessionID = previous?.state?.lastSessionId;
549
+ const nextSessionID = nextTask.state?.lastSessionId;
550
+ if (previousSessionID && previousSessionID !== nextSessionID) {
551
+ forgetLastSessionIfOwned(projectID, nextTask.id, previousSessionID);
552
+ }
415
553
  taskMap.set(nextTask.id, nextTask);
554
+ rememberLastSession(projectID, nextTask);
416
555
  };
417
556
 
418
557
  const syncTaskSchedule = async (projectID, task) => {
@@ -1278,14 +1417,17 @@ export const createScheduledTasksRuntime = (deps) => {
1278
1417
 
1279
1418
  const nextRunAt = computeNextRunAt(latestTask, finishedAt);
1280
1419
  try {
1281
- stateResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, {
1420
+ const statePatch = {
1282
1421
  lastStatus: status,
1283
1422
  lastDurationMs: durationMs,
1284
1423
  lastError: status === 'error' ? errorMessage : undefined,
1285
- lastSessionId: status === 'success' ? sessionID : undefined,
1286
1424
  nextRunAt: Number.isFinite(nextRunAt) ? nextRunAt : undefined,
1287
1425
  updatedAt: finishedAt,
1288
- });
1426
+ };
1427
+ if (sessionID) {
1428
+ statePatch.lastSessionId = sessionID;
1429
+ }
1430
+ stateResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, statePatch);
1289
1431
  if (stateResult.task) {
1290
1432
  updateInMemoryTask(projectID, stateResult.task);
1291
1433
  if (stateResult.task.enabled && Number.isFinite(stateResult.task.state?.nextRunAt)) {
@@ -1301,6 +1443,9 @@ export const createScheduledTasksRuntime = (deps) => {
1301
1443
  error: errorMessage,
1302
1444
  });
1303
1445
  }
1446
+ if (sessionID) {
1447
+ lastSessionOwners.set(sessionID, { projectID, taskID });
1448
+ }
1304
1449
 
1305
1450
  // History final status reflects the ultimate run outcome, including
1306
1451
  // state-persistence failures. A failed finalize leaves the row running
@@ -1441,6 +1586,91 @@ export const createScheduledTasksRuntime = (deps) => {
1441
1586
  };
1442
1587
  };
1443
1588
 
1589
+ const handleSessionIdleCorrection = async (event) => {
1590
+ const { idle, sessionID } = parseSessionEventIdle(event);
1591
+ if (!idle || !sessionID) {
1592
+ return;
1593
+ }
1594
+
1595
+ const owner = lastSessionOwners.get(sessionID);
1596
+ if (!owner) {
1597
+ return;
1598
+ }
1599
+
1600
+ const { projectID, taskID } = owner;
1601
+ const taskKey = buildTaskKey(projectID, taskID);
1602
+ if (runningTaskKeys.has(taskKey)) {
1603
+ return;
1604
+ }
1605
+
1606
+ const task = tasksByProject.get(projectID)?.get(taskID);
1607
+ const lastStatus = task?.state?.lastStatus;
1608
+ if (lastStatus === 'success' || lastStatus === 'running') {
1609
+ return;
1610
+ }
1611
+ if (lastStatus !== 'error') {
1612
+ return;
1613
+ }
1614
+
1615
+ const projectPath = projectPathByID.get(projectID) || await ensureProjectPath(projectID);
1616
+ if (!projectPath) {
1617
+ return;
1618
+ }
1619
+
1620
+ const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
1621
+ const authHeaders = getOpenCodeAuthHeaders();
1622
+ const client = createOpencodeClient({
1623
+ baseUrl,
1624
+ headers: authHeaders,
1625
+ });
1626
+ const snapshot = await snapshotSessionOutcome({
1627
+ client,
1628
+ sessionID,
1629
+ projectPath,
1630
+ goalEnabled: Boolean(task.execution?.goalEnabled),
1631
+ });
1632
+ if (snapshot.outcome !== 'success') {
1633
+ return;
1634
+ }
1635
+
1636
+ if (runningTaskKeys.has(taskKey)) {
1637
+ return;
1638
+ }
1639
+ const latest = tasksByProject.get(projectID)?.get(taskID);
1640
+ if (latest?.state?.lastStatus !== 'error') {
1641
+ return;
1642
+ }
1643
+
1644
+ const stateResult = await projectConfigRuntime.updateScheduledTaskState(projectID, taskID, {
1645
+ lastStatus: 'success',
1646
+ lastError: undefined,
1647
+ lastSessionId: sessionID,
1648
+ updatedAt: Date.now(),
1649
+ });
1650
+ if (stateResult.task) {
1651
+ updateInMemoryTask(projectID, stateResult.task);
1652
+ }
1653
+
1654
+ try {
1655
+ emitTaskRunEvent?.({
1656
+ projectID,
1657
+ taskID,
1658
+ ranAt: Date.now(),
1659
+ status: 'success',
1660
+ sessionID,
1661
+ });
1662
+ } catch {
1663
+ }
1664
+ };
1665
+
1666
+ const observeSessionEvent = (event) => {
1667
+ return handleSessionIdleCorrection(event).catch((error) => {
1668
+ logger.warn?.('[ScheduledTasks] observeSessionEvent failed', {
1669
+ error: safeErrorMessage(error),
1670
+ });
1671
+ });
1672
+ };
1673
+
1444
1674
  return {
1445
1675
  start,
1446
1676
  stop,
@@ -1448,5 +1678,6 @@ export const createScheduledTasksRuntime = (deps) => {
1448
1678
  syncProject,
1449
1679
  runNow,
1450
1680
  getStatus,
1681
+ observeSessionEvent,
1451
1682
  };
1452
1683
  };
@@ -993,4 +993,225 @@ describe('scheduled-tasks run history and session lifecycle', () => {
993
993
 
994
994
  vi.unstubAllGlobals();
995
995
  });
996
+
997
+ it('persists lastSessionId when an error run still created a session', async () => {
998
+ const history = createHistoryStore();
999
+ createSuccessfulClient({ settlement: 'assistant-error' });
1000
+ const fetchMock = vi.fn(async (url) => {
1001
+ if (String(url).includes('prompt_async')) {
1002
+ return { ok: true, text: async () => '' };
1003
+ }
1004
+ throw new Error(`unexpected fetch ${url}`);
1005
+ });
1006
+ vi.stubGlobal('fetch', fetchMock);
1007
+
1008
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1009
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1010
+ }));
1011
+ const runtime = createRuntime(updateScheduledTaskState, {
1012
+ runHistoryStore: history,
1013
+ waitForOpenCodeReady: vi.fn(async () => {}),
1014
+ });
1015
+ await runtime.syncProject('project-1');
1016
+ const result = await runtime.runNow('project-1', 'task-1');
1017
+
1018
+ expect(result.ok).toBe(false);
1019
+ expect(result.status).toBe('error');
1020
+ const finalPatch = updateScheduledTaskState.mock.calls.at(-1)[2];
1021
+ expect(finalPatch.lastStatus).toBe('error');
1022
+ expect(finalPatch.lastSessionId).toBe('ses_1');
1023
+
1024
+ vi.unstubAllGlobals();
1025
+ });
1026
+
1027
+ it('omits lastSessionId from the final patch when the failed run has no session', async () => {
1028
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1029
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1030
+ }));
1031
+ const runtime = createRuntime(updateScheduledTaskState);
1032
+ await runtime.syncProject('project-1');
1033
+ const result = await runtime.runNow('project-1', 'task-1');
1034
+
1035
+ expect(result.ok).toBe(false);
1036
+ expect(result.status).toBe('error');
1037
+ const finalPatch = updateScheduledTaskState.mock.calls.at(-1)[2];
1038
+ expect(finalPatch.lastStatus).toBe('error');
1039
+ expect(Object.hasOwn(finalPatch, 'lastSessionId')).toBe(false);
1040
+ });
1041
+
1042
+ it('corrects lastStatus error → success on idle after a continuation, without rewriting history', async () => {
1043
+ const history = createHistoryStore();
1044
+ const client = createSuccessfulClient({ settlement: 'assistant-error' });
1045
+ const fetchMock = vi.fn(async (url) => {
1046
+ if (String(url).includes('prompt_async')) {
1047
+ return { ok: true, text: async () => '' };
1048
+ }
1049
+ throw new Error(`unexpected fetch ${url}`);
1050
+ });
1051
+ vi.stubGlobal('fetch', fetchMock);
1052
+
1053
+ const emitTaskRunEvent = vi.fn();
1054
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1055
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1056
+ }));
1057
+ const runtime = createRuntime(updateScheduledTaskState, {
1058
+ runHistoryStore: history,
1059
+ waitForOpenCodeReady: vi.fn(async () => {}),
1060
+ emitTaskRunEvent,
1061
+ });
1062
+ await runtime.syncProject('project-1');
1063
+ const result = await runtime.runNow('project-1', 'task-1');
1064
+ expect(result.status).toBe('error');
1065
+ expect(history.finishRun).toHaveBeenCalledTimes(1);
1066
+
1067
+ client.messages.mockImplementation(async () => ({
1068
+ data: [{
1069
+ info: {
1070
+ id: 'msg_ok',
1071
+ role: 'assistant',
1072
+ time: { completed: Date.now() },
1073
+ },
1074
+ }],
1075
+ }));
1076
+
1077
+ await runtime.observeSessionEvent({
1078
+ payload: {
1079
+ type: 'session.idle',
1080
+ properties: { sessionID: 'ses_1' },
1081
+ },
1082
+ });
1083
+
1084
+ expect(history.finishRun).toHaveBeenCalledTimes(1);
1085
+ const correctionPatch = updateScheduledTaskState.mock.calls.at(-1)[2];
1086
+ expect(correctionPatch).toEqual(expect.objectContaining({
1087
+ lastStatus: 'success',
1088
+ lastError: undefined,
1089
+ lastSessionId: 'ses_1',
1090
+ }));
1091
+ expect(Object.hasOwn(correctionPatch, 'lastRunAt')).toBe(false);
1092
+ expect(Object.hasOwn(correctionPatch, 'lastDurationMs')).toBe(false);
1093
+ expect(Object.hasOwn(correctionPatch, 'nextRunAt')).toBe(false);
1094
+ expect(emitTaskRunEvent).toHaveBeenCalledWith(expect.objectContaining({
1095
+ projectID: 'project-1',
1096
+ taskID: 'task-1',
1097
+ status: 'success',
1098
+ sessionID: 'ses_1',
1099
+ }));
1100
+
1101
+ vi.unstubAllGlobals();
1102
+ });
1103
+
1104
+ it('does not correct lastStatus while the task is still running', async () => {
1105
+ vi.useFakeTimers();
1106
+ const history = createHistoryStore();
1107
+ const erroredTask = {
1108
+ ...scheduledTask,
1109
+ state: {
1110
+ ...scheduledTask.state,
1111
+ lastStatus: 'error',
1112
+ lastSessionId: 'ses_1',
1113
+ lastError: 'ProviderError',
1114
+ },
1115
+ };
1116
+ createSuccessfulClient({ settlement: 'busy-then-success' });
1117
+ const fetchMock = vi.fn(async (url) => {
1118
+ if (String(url).includes('prompt_async')) {
1119
+ return { ok: true, text: async () => '' };
1120
+ }
1121
+ throw new Error(`unexpected fetch ${url}`);
1122
+ });
1123
+ vi.stubGlobal('fetch', fetchMock);
1124
+
1125
+ const emitTaskRunEvent = vi.fn();
1126
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1127
+ task: { ...erroredTask, state: { ...erroredTask.state, ...state } },
1128
+ }));
1129
+ const runtime = createRuntime(updateScheduledTaskState, {
1130
+ projectConfigRuntime: {
1131
+ listScheduledTasks: vi.fn(async () => [erroredTask]),
1132
+ updateScheduledTaskState,
1133
+ upsertScheduledTask: vi.fn(),
1134
+ },
1135
+ runHistoryStore: history,
1136
+ waitForOpenCodeReady: vi.fn(async () => {}),
1137
+ emitTaskRunEvent,
1138
+ });
1139
+ await runtime.syncProject('project-1');
1140
+
1141
+ const runPromise = runtime.runNow('project-1', 'task-1');
1142
+ await vi.advanceTimersByTimeAsync(0);
1143
+ expect(history.finishRun).not.toHaveBeenCalled();
1144
+
1145
+ const successEmitsBefore = emitTaskRunEvent.mock.calls.filter((call) => call[0].status === 'success').length;
1146
+ await runtime.observeSessionEvent({
1147
+ payload: {
1148
+ type: 'session.idle',
1149
+ properties: { sessionID: 'ses_1' },
1150
+ },
1151
+ });
1152
+ expect(history.finishRun).not.toHaveBeenCalled();
1153
+ expect(updateScheduledTaskState.mock.calls.map((call) => call[2].lastStatus)).not.toContain('success');
1154
+ expect(emitTaskRunEvent.mock.calls.filter((call) => call[0].status === 'success')).toHaveLength(successEmitsBefore);
1155
+
1156
+ await vi.advanceTimersByTimeAsync(1_000);
1157
+ const result = await runPromise;
1158
+ expect(result.ok).toBe(true);
1159
+
1160
+ vi.unstubAllGlobals();
1161
+ vi.useRealTimers();
1162
+ });
1163
+
1164
+ it('does not correct lastStatus when it is already success', async () => {
1165
+ const history = createHistoryStore();
1166
+ const client = createSuccessfulClient();
1167
+ const fetchMock = vi.fn(async (url) => {
1168
+ if (String(url).includes('prompt_async')) {
1169
+ return { ok: true, text: async () => '' };
1170
+ }
1171
+ throw new Error(`unexpected fetch ${url}`);
1172
+ });
1173
+ vi.stubGlobal('fetch', fetchMock);
1174
+
1175
+ const emitTaskRunEvent = vi.fn();
1176
+ const updateScheduledTaskState = vi.fn(async (_projectID, _taskID, state) => ({
1177
+ task: { ...scheduledTask, state: { ...scheduledTask.state, ...state } },
1178
+ }));
1179
+ const runtime = createRuntime(updateScheduledTaskState, {
1180
+ runHistoryStore: history,
1181
+ waitForOpenCodeReady: vi.fn(async () => {}),
1182
+ emitTaskRunEvent,
1183
+ });
1184
+ await runtime.syncProject('project-1');
1185
+ const result = await runtime.runNow('project-1', 'task-1');
1186
+ expect(result.ok).toBe(true);
1187
+ expect(history.finishRun).toHaveBeenCalledTimes(1);
1188
+ const stateWritesAfterRun = updateScheduledTaskState.mock.calls.length;
1189
+ const successEmitsAfterRun = emitTaskRunEvent.mock.calls.filter((call) => call[0].status === 'success').length;
1190
+
1191
+ client.messages.mockImplementation(async () => ({
1192
+ data: [{
1193
+ info: {
1194
+ id: 'msg_ok',
1195
+ role: 'assistant',
1196
+ time: { completed: Date.now() },
1197
+ },
1198
+ }],
1199
+ }));
1200
+
1201
+ await runtime.observeSessionEvent({
1202
+ payload: {
1203
+ type: 'session.status',
1204
+ properties: {
1205
+ sessionID: 'ses_1',
1206
+ status: { type: 'idle' },
1207
+ },
1208
+ },
1209
+ });
1210
+
1211
+ expect(history.finishRun).toHaveBeenCalledTimes(1);
1212
+ expect(updateScheduledTaskState).toHaveBeenCalledTimes(stateWritesAfterRun);
1213
+ expect(emitTaskRunEvent.mock.calls.filter((call) => call[0].status === 'success')).toHaveLength(successEmitsAfterRun);
1214
+
1215
+ vi.unstubAllGlobals();
1216
+ });
996
1217
  });