donobu 5.54.0 → 5.56.0

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 (53) hide show
  1. package/dist/apis/FlowsApi.d.ts +95 -7
  2. package/dist/apis/FlowsApi.js +139 -11
  3. package/dist/apis/TestsApi.js +4 -3
  4. package/dist/codegen/CodeGenerator.js +4 -2
  5. package/dist/esm/apis/FlowsApi.d.ts +95 -7
  6. package/dist/esm/apis/FlowsApi.js +139 -11
  7. package/dist/esm/apis/TestsApi.js +4 -3
  8. package/dist/esm/codegen/CodeGenerator.js +4 -2
  9. package/dist/esm/managers/AdminApiController.js +4 -0
  10. package/dist/esm/managers/DonobuFlow.d.ts +111 -1
  11. package/dist/esm/managers/DonobuFlow.js +443 -24
  12. package/dist/esm/managers/DonobuFlowsManager.d.ts +14 -1
  13. package/dist/esm/managers/DonobuFlowsManager.js +28 -6
  14. package/dist/esm/models/ControlPanel.d.ts +30 -3
  15. package/dist/esm/models/CreateDonobuFlow.d.ts +1 -0
  16. package/dist/esm/models/CreateTest.d.ts +1 -0
  17. package/dist/esm/models/FlowMetadata.d.ts +6 -0
  18. package/dist/esm/models/FlowMetadata.js +3 -1
  19. package/dist/esm/models/RunMode.d.ts +1 -0
  20. package/dist/esm/models/RunMode.js +7 -1
  21. package/dist/esm/models/TestMetadata.d.ts +9 -0
  22. package/dist/esm/persistence/DonobuSqliteDb.js +3 -2
  23. package/dist/esm/tools/AcknowledgeUserInstruction.d.ts +6 -0
  24. package/dist/esm/tools/AcknowledgeUserInstruction.js +7 -0
  25. package/dist/esm/tools/ReplayableInteraction.d.ts +20 -0
  26. package/dist/esm/tools/ReplayableInteraction.js +63 -0
  27. package/dist/esm/tools/SetRunModeTool.d.ts +2 -0
  28. package/dist/esm/tools/Tool.d.ts +22 -3
  29. package/dist/esm/tools/Tool.js +21 -2
  30. package/dist/esm/tools/TriggerDonobuFlowTool.d.ts +2 -0
  31. package/dist/managers/AdminApiController.js +4 -0
  32. package/dist/managers/DonobuFlow.d.ts +111 -1
  33. package/dist/managers/DonobuFlow.js +443 -24
  34. package/dist/managers/DonobuFlowsManager.d.ts +14 -1
  35. package/dist/managers/DonobuFlowsManager.js +28 -6
  36. package/dist/models/ControlPanel.d.ts +30 -3
  37. package/dist/models/CreateDonobuFlow.d.ts +1 -0
  38. package/dist/models/CreateTest.d.ts +1 -0
  39. package/dist/models/FlowMetadata.d.ts +6 -0
  40. package/dist/models/FlowMetadata.js +3 -1
  41. package/dist/models/RunMode.d.ts +1 -0
  42. package/dist/models/RunMode.js +7 -1
  43. package/dist/models/TestMetadata.d.ts +9 -0
  44. package/dist/persistence/DonobuSqliteDb.js +3 -2
  45. package/dist/tools/AcknowledgeUserInstruction.d.ts +6 -0
  46. package/dist/tools/AcknowledgeUserInstruction.js +7 -0
  47. package/dist/tools/ReplayableInteraction.d.ts +20 -0
  48. package/dist/tools/ReplayableInteraction.js +63 -0
  49. package/dist/tools/SetRunModeTool.d.ts +2 -0
  50. package/dist/tools/Tool.d.ts +22 -3
  51. package/dist/tools/Tool.js +21 -2
  52. package/dist/tools/TriggerDonobuFlowTool.d.ts +2 -0
  53. package/package.json +1 -1
@@ -5,6 +5,16 @@ const v4_1 = require("zod/v4");
5
5
  const CodeGenerationOptions_1 = require("../models/CodeGenerationOptions");
6
6
  const CreateDonobuFlow_1 = require("../models/CreateDonobuFlow");
7
7
  const FlowMetadata_1 = require("../models/FlowMetadata");
8
+ const RunMode_1 = require("../models/RunMode");
9
+ /** Body schema for the reject-proposal endpoint (SUPERVISED mode). */
10
+ const RejectProposalBodySchema = v4_1.z.object({
11
+ feedback: v4_1.z.string().optional(),
12
+ });
13
+ /** Body schema for the set-run-mode endpoint (live autonomy switching). */
14
+ const SetRunModeBodySchema = v4_1.z.object({
15
+ runMode: RunMode_1.RunModeSchema,
16
+ approvePending: v4_1.z.boolean().optional(),
17
+ });
8
18
  /**
9
19
  * API controller for managing Donobu flows throughout their lifecycle.
10
20
  *
@@ -284,9 +294,11 @@ class FlowsApi {
284
294
  /**
285
295
  * Pauses execution of an active flow.
286
296
  *
287
- * Signals the flow to pause after completing its current action. The flow
288
- * will transition to PAUSED state and can be resumed later. Only works
289
- * for flows that are not already in a terminal state.
297
+ * Submits a PAUSE user action; the flow's run loop applies it after its
298
+ * current action, transitioning to PAUSED. Like the desktop control panel's
299
+ * pause, this goes through the single user-action channel
300
+ * ({@link DonobuFlow.submitUserAction}) rather than mutating flow state
301
+ * directly.
290
302
  *
291
303
  * @throws {@link ActiveFlowNotFoundException} When the flow is not active locally
292
304
  *
@@ -294,7 +306,8 @@ class FlowsApi {
294
306
  * ```http
295
307
  * POST /api/flows/abc123/pause
296
308
  *
297
- * Response: FlowMetadata with nextState set to "PAUSED"
309
+ * Response: the flow's FlowMetadata (the pause is applied asynchronously by
310
+ * the run loop, so the returned metadata may not yet show PAUSED)
298
311
  * ```
299
312
  *
300
313
  * @remarks
@@ -304,16 +317,18 @@ class FlowsApi {
304
317
  async pauseFlow(req, res) {
305
318
  const flowId = String(req.params.flowId);
306
319
  const flow = this.donobuFlowsManager.getActiveFlow(flowId);
307
- if (!(0, FlowMetadata_1.isComplete)(flow.metadata.state)) {
308
- flow.metadata.nextState = 'PAUSED';
309
- }
320
+ // The PAUSE handler in onUserInterruption no-ops if the flow is already
321
+ // complete, so no guard is needed here.
322
+ flow.submitUserAction({ type: 'PAUSE' });
310
323
  res.json(flow.metadata);
311
324
  }
312
325
  /**
313
326
  * Resumes execution of a paused flow.
314
327
  *
315
- * Signals a paused flow to continue execution. The flow will transition
316
- * from PAUSED to RESUMING state and then continue normal operation.
328
+ * Submits a RESUME user action (only when the flow is currently PAUSED); the
329
+ * run loop transitions PAUSED RESUMING normal operation. Like the desktop
330
+ * control panel's resume, this goes through the single user-action channel
331
+ * ({@link DonobuFlow.submitUserAction}).
317
332
  *
318
333
  * @throws {@link ActiveFlowNotFoundException} When the flow is not active locally
319
334
  *
@@ -321,7 +336,8 @@ class FlowsApi {
321
336
  * ```http
322
337
  * POST /api/flows/abc123/resume
323
338
  *
324
- * Response: FlowMetadata with nextState set to "RESUMING"
339
+ * Response: the flow's FlowMetadata (the resume is applied asynchronously by
340
+ * the run loop, so the returned metadata may not yet show RESUMING)
325
341
  * ```
326
342
  *
327
343
  * @remarks
@@ -332,8 +348,120 @@ class FlowsApi {
332
348
  const flowId = String(req.params.flowId);
333
349
  const flow = this.donobuFlowsManager.getActiveFlow(flowId);
334
350
  if (flow.metadata.state === 'PAUSED') {
335
- flow.metadata.nextState = 'RESUMING';
351
+ flow.submitUserAction({ type: 'RESUME' });
352
+ }
353
+ res.json(flow.metadata);
354
+ }
355
+ /**
356
+ * Approves the AI-proposed action(s) a SUPERVISED flow is waiting on.
357
+ *
358
+ * The flow leaves WAITING_FOR_APPROVAL and executes the proposed action(s)
359
+ * as-is, then continues (proposing its next action for approval).
360
+ *
361
+ * @throws {@link ActiveFlowNotFoundException} When the flow is not active locally
362
+ *
363
+ * @example
364
+ * ```http
365
+ * POST /api/flows/abc123/approve
366
+ *
367
+ * Response: FlowMetadata
368
+ * ```
369
+ *
370
+ * @remarks
371
+ * This endpoint is only available in LOCAL deployment environments for security.
372
+ * Only has an effect on flows currently in WAITING_FOR_APPROVAL state.
373
+ */
374
+ async approveProposal(req, res) {
375
+ const flowId = String(req.params.flowId);
376
+ const flow = this.donobuFlowsManager.getActiveFlow(flowId);
377
+ flow.submitUserAction({ type: 'APPROVE' });
378
+ res.json(flow.metadata);
379
+ }
380
+ /**
381
+ * Rejects the AI-proposed action(s) a SUPERVISED flow is waiting on.
382
+ *
383
+ * The proposed action(s) are discarded and the optional feedback is fed back
384
+ * to the AI, which then proposes a different next action (again awaiting
385
+ * approval).
386
+ *
387
+ * @throws {@link ActiveFlowNotFoundException} When the flow is not active locally
388
+ *
389
+ * @example
390
+ * ```http
391
+ * POST /api/flows/abc123/reject
392
+ * { "feedback": "Use the search box instead of the menu." }
393
+ *
394
+ * Response: FlowMetadata
395
+ * ```
396
+ *
397
+ * @remarks
398
+ * This endpoint is only available in LOCAL deployment environments for security.
399
+ * Only has an effect on flows currently in WAITING_FOR_APPROVAL state.
400
+ */
401
+ async rejectProposal(req, res) {
402
+ const flowId = String(req.params.flowId);
403
+ const flow = this.donobuFlowsManager.getActiveFlow(flowId);
404
+ const { feedback } = RejectProposalBodySchema.parse(req.body ?? {});
405
+ flow.submitUserAction({ type: 'REJECT', feedback });
406
+ res.json(flow.metadata);
407
+ }
408
+ /**
409
+ * Returns the AI-proposed tool call(s) a SUPERVISED flow is currently waiting
410
+ * on for approval. Empty when the flow is not active locally or is not in the
411
+ * WAITING_FOR_APPROVAL state. Polled by the UI to render what the user is
412
+ * being asked to approve or reject.
413
+ *
414
+ * @example
415
+ * ```http
416
+ * GET /api/flows/abc123/pending-tool-calls
417
+ *
418
+ * Response: { pendingToolCalls: ProposedToolCall[] }
419
+ * ```
420
+ *
421
+ * @remarks
422
+ * This endpoint is only available in LOCAL deployment environments.
423
+ */
424
+ async getPendingToolCalls(req, res) {
425
+ const flowId = String(req.params.flowId);
426
+ let pendingToolCalls = [];
427
+ try {
428
+ const flow = this.donobuFlowsManager.getActiveFlow(flowId);
429
+ if (flow.metadata.state === 'WAITING_FOR_APPROVAL') {
430
+ pendingToolCalls = [...flow.proposedToolCalls];
431
+ }
336
432
  }
433
+ catch {
434
+ // Flow isn't active locally — nothing is pending approval.
435
+ }
436
+ res.json({ pendingToolCalls });
437
+ }
438
+ /**
439
+ * Changes the run mode of an active flow at runtime — the autonomy selector.
440
+ *
441
+ * This is the general primitive behind "start asking me each step"
442
+ * (→ SUPERVISED), "go fully autonomous" (→ AUTONOMOUS), and "I'll take over"
443
+ * (→ INSTRUCT). When switching to AUTONOMOUS with `approvePending: true` and a
444
+ * proposal is awaiting approval, that proposal is approved and run as part of
445
+ * the switch (the "approve & let it run" shortcut).
446
+ *
447
+ * @throws {@link ActiveFlowNotFoundException} When the flow is not active locally
448
+ *
449
+ * @example
450
+ * ```http
451
+ * POST /api/flows/abc123/run-mode
452
+ * { "runMode": "AUTONOMOUS", "approvePending": true }
453
+ *
454
+ * Response: FlowMetadata
455
+ * ```
456
+ *
457
+ * @remarks
458
+ * This endpoint is only available in LOCAL deployment environments.
459
+ */
460
+ async setRunMode(req, res) {
461
+ const flowId = String(req.params.flowId);
462
+ const flow = this.donobuFlowsManager.getActiveFlow(flowId);
463
+ const { runMode, approvePending } = SetRunModeBodySchema.parse(req.body ?? {});
464
+ flow.submitUserAction({ type: 'SET_RUN_MODE', runMode, approvePending });
337
465
  res.json(flow.metadata);
338
466
  }
339
467
  /**
@@ -70,9 +70,10 @@ class TestsApi {
70
70
  const testId = String(req.params.testId);
71
71
  const newFlowConfig = await this.testsManager.getNewFlowFromTest(testId);
72
72
  const flowHandle = await this.flowsManager.createFlow(newFlowConfig);
73
- // After starting an autonomous run, switch the test to deterministic
74
- // so subsequent runs replay the recorded actions.
75
- if (newFlowConfig.initialRunMode === 'AUTONOMOUS') {
73
+ // After starting an AI-driven run (autonomous or supervised), switch the
74
+ // test to deterministic so subsequent runs replay the recorded actions.
75
+ if (newFlowConfig.initialRunMode === 'AUTONOMOUS' ||
76
+ newFlowConfig.initialRunMode === 'SUPERVISED') {
76
77
  const test = await this.testsManager.getTestById(testId);
77
78
  await this.testsManager.updateTest({
78
79
  ...test,
@@ -480,7 +480,9 @@ async function buildCacheContents(flowsWithToolCalls, toolRegistry) {
480
480
  const minimalToolNames = new Set(toolRegistry.minimalTools().map((t) => t.name));
481
481
  const entries = flowsWithToolCalls
482
482
  // We can only create page.ai caches for flows that have an objective.
483
- .filter(({ metadata }) => metadata.overallObjective?.trim() && metadata.runMode === 'AUTONOMOUS')
483
+ .filter(({ metadata }) => metadata.overallObjective?.trim() &&
484
+ (metadata.runMode === 'AUTONOMOUS' ||
485
+ metadata.runMode === 'SUPERVISED'))
484
486
  .map(({ metadata, toolCalls }) => {
485
487
  const [firstToolCall, ...remaingToolCalls] = toolCalls;
486
488
  // If the first tool call is "GoToWebpage", then we peel it off and treat it
@@ -805,7 +807,7 @@ async function generateTestFiles(flowsWithToolCalls, options, toolRegistry) {
805
807
  const fileName = getTestFileName(metadata);
806
808
  const content = scriptVariant === 'classic' ||
807
809
  !metadata.overallObjective?.trim() ||
808
- metadata.runMode !== 'AUTONOMOUS'
810
+ (metadata.runMode !== 'AUTONOMOUS' && metadata.runMode !== 'SUPERVISED')
809
811
  ? await getFlowAsPlaywrightScript(metadata, toolCalls, options, toolRegistry)
810
812
  : await getFlowAsAiPlaywrightScript(metadata, toolCalls, options, toolRegistry);
811
813
  files.push({
@@ -287,6 +287,10 @@ class AdminApiController {
287
287
  app.post('/api/flows/:flowId/cancel', this.asyncHandler(apis.flowsApi.cancelFlow.bind(apis.flowsApi)));
288
288
  app.post('/api/flows/:flowId/pause', this.asyncHandler(apis.flowsApi.pauseFlow.bind(apis.flowsApi)));
289
289
  app.post('/api/flows/:flowId/resume', this.asyncHandler(apis.flowsApi.resumeFlow.bind(apis.flowsApi)));
290
+ app.post('/api/flows/:flowId/approve', this.asyncHandler(apis.flowsApi.approveProposal.bind(apis.flowsApi)));
291
+ app.post('/api/flows/:flowId/reject', this.asyncHandler(apis.flowsApi.rejectProposal.bind(apis.flowsApi)));
292
+ app.get('/api/flows/:flowId/pending-tool-calls', this.asyncHandler(apis.flowsApi.getPendingToolCalls.bind(apis.flowsApi)));
293
+ app.post('/api/flows/:flowId/run-mode', this.asyncHandler(apis.flowsApi.setRunMode.bind(apis.flowsApi)));
290
294
  app.post('/api/flows/:flowId/tool-calls', this.asyncHandler(apis.flowsToolCallsApi.postToolCalls.bind(apis.flowsToolCallsApi)));
291
295
  }
292
296
  /**
@@ -1,7 +1,7 @@
1
1
  import type { z } from 'zod/v4';
2
2
  import type { GptClient } from '../clients/GptClient';
3
3
  import type { AiQuery } from '../models/AiQuery';
4
- import type { ControlPanel } from '../models/ControlPanel';
4
+ import type { ControlPanel, UserAction } from '../models/ControlPanel';
5
5
  import type { FlowMetadata } from '../models/FlowMetadata';
6
6
  import type { GptMessage, StructuredOutputMessage, TextItem } from '../models/GptMessage';
7
7
  import type { SystemMessage } from '../models/GptMessage';
@@ -40,8 +40,23 @@ export declare class DonobuFlow {
40
40
  readonly controlPanel: ControlPanel;
41
41
  private static readonly MAIN_MESSAGE_ELEMENT_LIST_MARKER;
42
42
  static readonly USER_INTERRUPT_MARKER = "[User interruption while flow was paused, this MUST be acknowledged]";
43
+ static readonly REJECTION_MARKER = "[The user rejected your previously proposed action(s). Do NOT repeat them. Propose a different next action, taking the following feedback into account]";
43
44
  inProgressToolCall: ToolCall | null;
44
45
  readonly aiQueries: AiQuery[];
46
+ /**
47
+ * In SUPERVISED mode, the set of `toolCallId`s the user has explicitly
48
+ * approved. A proposed tool call only executes once its id is in this set;
49
+ * AI-proposed calls whose id is absent park the flow in
50
+ * `WAITING_FOR_APPROVAL`. Ids are removed as their calls run, so the set only
51
+ * ever holds currently-pending approvals.
52
+ */
53
+ private readonly approvedToolCallIds;
54
+ /**
55
+ * User actions submitted out-of-band (e.g. via REST endpoints rather than the
56
+ * desktop control panel). Drained by the run loop alongside the control
57
+ * panel, so both surfaces drive the flow through the same code path.
58
+ */
59
+ private readonly userActionInbox;
45
60
  constructor(flowsManager: DonobuFlowsManager, envData: Record<string, string>, persistence: FlowsPersistence, gptClient: GptClient | null, toolManager: ToolManager, interactionVisualizer: InteractionVisualizer, proposedToolCalls: ProposedToolCall[], invokedToolCalls: ToolCall[], gptMessages: GptMessage[], targetInspector: TargetInspector, metadata: FlowMetadata, controlPanel: ControlPanel);
46
61
  /**
47
62
  * Drives the entire Donobu flow state-machine until it reaches a
@@ -78,6 +93,25 @@ export declare class DonobuFlow {
78
93
  * explicit result.
79
94
  */
80
95
  run(): Promise<FlowMetadata['result']>;
96
+ /**
97
+ * The single entry point for external user imperatives. Every cooperative
98
+ * control interrupt — pause, resume, end, approve, reject, run-mode change —
99
+ * arrives here as a {@link UserAction}, whether it came from a REST endpoint
100
+ * (web frontend / SDK) or the desktop control panel. The action is queued and
101
+ * drained by the run loop ({@link popUserAction}) and handled uniformly by
102
+ * {@link onUserInterruption}, so all transports drive the flow identically.
103
+ *
104
+ * (The forceful `cancelFlow` and the queue-injecting `proposeToolCall` on
105
+ * {@link DonobuFlowsManager} intentionally do NOT use this path — see their
106
+ * docs.)
107
+ */
108
+ submitUserAction(action: UserAction): void;
109
+ /**
110
+ * Returns and clears the next pending user action, preferring out-of-band
111
+ * actions (REST) over the control panel. Both sources feed the same
112
+ * intervention path so the desktop and web surfaces behave identically.
113
+ */
114
+ private popUserAction;
81
115
  /**
82
116
  * Delegates to the inspector to attempt recovery after the target is
83
117
  * closed. If recovery fails, the flow is marked as failed.
@@ -100,6 +134,53 @@ export declare class DonobuFlow {
100
134
  * Note that this *bypasses* the normal state transition logic!
101
135
  */
102
136
  private onUserInterruption;
137
+ /**
138
+ * Incorporates the compose-field text from a ▶/⏩ action: if the flow has no
139
+ * standing goal yet, the text becomes the `overallObjective`; otherwise it's
140
+ * added as extra guidance. Either way it's injected into the LLM history (the
141
+ * system prompt was built at init, possibly before any objective existed) and
142
+ * recorded in the timeline. No-op for empty text.
143
+ */
144
+ private applyComposeInstruction;
145
+ /**
146
+ * Closes out the currently-proposed AI tool call(s) without executing them:
147
+ * emits a `tool_call_result` for each (so the LLM message history stays
148
+ * well-formed — every tool call needs a matching result) and clears the
149
+ * proposal queue and any pending approvals. Shared by REJECT and manual
150
+ * takeover.
151
+ */
152
+ private closeOutPendingProposals;
153
+ /**
154
+ * Records a synthetic {@link AcknowledgeUserInstructionTool} tool call so a
155
+ * user-driven event (rejection, mode change) shows up in the flow timeline.
156
+ * Mirrors how RESUME records a user instruction.
157
+ */
158
+ private recordAdHocToolCall;
159
+ /**
160
+ * Moves the flow along the autonomy axis at runtime — the primitive behind
161
+ * "start asking me" (→ SUPERVISED), "go fully autonomous" (→ AUTONOMOUS),
162
+ * and "I'll take over" (→ INSTRUCT). After adjusting `runMode` and the
163
+ * pending proposal as appropriate, it routes through RESUMING so the next
164
+ * {@link transitionState} recomputes the correct state under the new mode.
165
+ *
166
+ * @param runMode - The target live mode. DETERMINISTIC is not a live mode and
167
+ * is ignored. AI modes (AUTONOMOUS/SUPERVISED) require a GPT client.
168
+ * @param approvePending - When switching to AUTONOMOUS with an AI proposal
169
+ * awaiting approval, approve and run it as part of the switch.
170
+ */
171
+ private applyRunModeChange;
172
+ /**
173
+ * Whether the flow can hand control to the AI: it needs both a GPT client and
174
+ * a goal to pursue.
175
+ */
176
+ private canHandOffToAi;
177
+ /**
178
+ * Whether there is a standing goal for the AI to pursue (a non-empty
179
+ * `overallObjective`). Surfaced to the UI as `hasGoal` to drive the
180
+ * transport: ⏩ Fast-forward (autonomous run) is only offered with a goal,
181
+ * and ▶ Play needs either a goal or a typed instruction.
182
+ */
183
+ private hasGoal;
103
184
  /**
104
185
  * This method is called if there is an unhandled unexpected exception. This
105
186
  * method will mark the flow as a failure.
@@ -171,9 +252,38 @@ export declare class DonobuFlow {
171
252
  * initializes the GPT message history.
172
253
  */
173
254
  private onInitializing;
255
+ /**
256
+ * Assembles the {@link ToolCallContext} handed to a tool. Shared by actual
257
+ * execution ({@link onRunningAction}) and the SUPERVISED-mode cursor preview
258
+ * ({@link previewProposedInteraction}) so both see an identical environment.
259
+ */
260
+ private buildToolCallContext;
261
+ /**
262
+ * SUPERVISED mode: move the on-screen cursor to where the head proposed
263
+ * action *would* interact, so the user can see the target while deciding
264
+ * whether to approve it. This never executes the action — it only previews
265
+ * the interaction point. Best-effort: tools without a visible target (and any
266
+ * resolution failure) are simply skipped.
267
+ */
268
+ private previewProposedInteraction;
174
269
  private onRunningAction;
175
270
  private onQueryingLlmForNextAction;
176
271
  private onWaitingForUserForNextAction;
272
+ /**
273
+ * SUPERVISED mode: an AI-proposed action is parked awaiting the user's
274
+ * decision. We idle here until an APPROVE/REJECT (or other intervention)
275
+ * arrives via the control panel or a REST endpoint, which the run loop picks
276
+ * up as a {@link UserInterruptException}. Mirrors
277
+ * {@link onWaitingForUserForNextAction}.
278
+ *
279
+ * Unlike {@link onPaused}, we must NOT pin `nextState` here: the proposal
280
+ * still sits in `proposedToolCalls`, so the approval gate in
281
+ * {@link transitionState} re-parks us each poll on its own. Pinning it would
282
+ * also leave a stale `nextState` that survives an APPROVE interrupt (which
283
+ * sets `state` directly), causing the next transition to skip querying the
284
+ * LLM and park forever with an empty proposal queue.
285
+ */
286
+ private onWaitingForApproval;
177
287
  private onPaused;
178
288
  private onResuming;
179
289
  private onFailed;