Skip to content

Commit 1a659bc

Browse files
saurabhjain1592Saurabh Jain
andauthored
fix(planning): resumePlan surfaces step-mode HITL fields (#246)
* fix(planning): resumePlan surfaces step-mode HITL fields The orchestrator's confirm/step-mode resume path emits step_result, next_step, next_step_name, total_steps and workflow_id (plus message on terminal paths), and the v9.6.1 community OpenAPI spec declares all of them on ResumePlanResponse. The TypeScript resumePlan transformer dropped every one of them, and the ResumePlanResponse type doc wrongly marked the camelCase slots as deprecated/never-populated. - src/client.ts: map workflow_id, step_result, next_step, next_step_name and total_steps onto the result (existing mappings preserved). - src/types/planning.ts: un-deprecate workflowId, message, stepResult, nextStep, nextStepName and totalSteps with accurate wire-path docs (step-mode HITL vs terminal resume). message is a real wire field (terminal-path outcome summary), not a workflowId alias. - tests: new resumePlan unit tests covering the step-gating payload, the terminal completed path, the rejected path, and the error path. - wire-shape baseline regenerated against the same pin (community ac1b7cd8, unchanged): the ResumePlanResponse drift entry and its note drop; acknowledged _note keys on DecideResponse and MCPCheckOutputResponse re-added (refresh.js strips them). - CHANGELOG: Fixed bullet under the un-cut 8.5.1 section; version unchanged. Refs getaxonflow/axonflow-enterprise#2861 Signed-off-by: Saurabh Jain <dev@getaxonflow.com> * test(runtime-e2e): resumePlan confirm-mode loop against a live agent Adds runtime-e2e/resume_plan_step_fields/: generatePlan(executionMode: 'confirm') → executePlan (WCP gates step 0) → resumePlan per gated step → terminal resume, against a real Enterprise agent. Asserts every non-terminal resume carries workflow_id/step_result/next_step/ next_step_name/total_steps and the terminal resume carries workflow_id + message with no step-gating fields. Verified green against the live local Enterprise stack (RESULT: PASS, 12/12 checks). Refs getaxonflow/axonflow-enterprise#2861 Signed-off-by: Saurabh Jain <dev@getaxonflow.com> --------- Signed-off-by: Saurabh Jain <dev@getaxonflow.com> Co-authored-by: Saurabh Jain <dev@getaxonflow.com>
1 parent 25fa7dc commit 1a659bc

6 files changed

Lines changed: 277 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,24 @@ Hostile-testing sweep ahead of the BukuWarung integration
2828
read `AXONFLOW_USER_TOKEN`, thread it through `generatePlan`/`executePlan`
2929
and `queryConnector`, and set a non-zero exit code on unexpected failures
3030
(`PolicyViolationError` remains an expected demo outcome).
31+
- **`resumePlan` surfaces the step-mode HITL fields the platform emits when
32+
gating the next step**`next_step`, `next_step_name`, `step_result`,
33+
`total_steps` and `workflow_id` now map onto `ResumePlanResponse`
34+
(`nextStep`/`nextStepName`/`stepResult`/`totalSteps`/`workflowId`). The
35+
transformer dropped them and the type doc wrongly marked them
36+
never-populated/deprecated; the orchestrator's confirm/step-mode resume
37+
path has emitted them all along. Their `ResumePlanResponse` entries drop
38+
out of the wire-shape drift baseline.
3139

3240
### Added
3341

3442
- `runtime-e2e/plan_status_auth/` — live-agent assertion: generatePlan →
3543
getPlanStatus authenticated round-trip + wrong-credentials 401 control.
44+
- `runtime-e2e/resume_plan_step_fields/` — live-agent assertion for the
45+
resumePlan fix: drives the real confirm-mode loop (generatePlan →
46+
executePlan → resumePlan per gated step → terminal resume) and asserts
47+
the step-mode HITL fields land on every gate and are absent on the
48+
terminal response.
3649

3750
## [8.5.0] - 2026-06-09 — Decision Mode PEP: decide → fulfill → forward
3851

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// runtime-e2e/resume_plan_step_fields/test.mjs
2+
// Real-stack assertion: resumePlan surfaces the step/confirm-mode HITL fields
3+
// the orchestrator emits when it gates the next step (step_result, next_step,
4+
// next_step_name, total_steps) plus workflow_id on every resume path and
5+
// message on terminal paths. Pre-fix, the transformer dropped all of them and
6+
// the ResumePlanResponse doc marked them "never populated".
7+
// Per CLAUDE.md HARD RULE #0 — this test MUST hit a real agent, no mocks.
8+
//
9+
// Drives the real confirm-mode loop end-to-end:
10+
// generatePlan(executionMode: 'confirm') → executePlan (WCP gates step 0,
11+
// awaiting_approval) → resumePlan(approved) per step → terminal resume.
12+
//
13+
// Requires an Enterprise stack (confirm/step mode is license-gated).
14+
// Run (after `source /tmp/axonflow-e2e-env.sh` from the enterprise setup
15+
// script; npm run build first):
16+
//
17+
// AXONFLOW_AGENT_URL=http://localhost:8080 node runtime-e2e/resume_plan_step_fields/test.mjs
18+
19+
import { AxonFlow } from '../../dist/esm/index.js';
20+
21+
const endpoint = process.env.AXONFLOW_AGENT_URL || 'http://localhost:8080';
22+
const clientId = process.env.AXONFLOW_CLIENT_ID;
23+
const clientSecret = process.env.AXONFLOW_CLIENT_SECRET;
24+
const userToken = process.env.AXONFLOW_USER_TOKEN || '';
25+
if (!clientId || !clientSecret) {
26+
console.error('AXONFLOW_CLIENT_ID + AXONFLOW_CLIENT_SECRET must be set; see ../README.md');
27+
process.exit(2);
28+
}
29+
30+
const client = new AxonFlow({ clientId, clientSecret, endpoint, timeout: 120000 });
31+
32+
let failures = 0;
33+
const check = (name, ok, detail = '') => {
34+
console.log(`${ok ? 'PASS' : 'FAIL'} [${name}] ${detail}`);
35+
if (!ok) failures++;
36+
};
37+
38+
try {
39+
// 1. Generate a multi-step plan in confirm mode (every step gated).
40+
const plan = await client.generatePlan(
41+
'Plan a two-step research task: gather sources, then summarize findings',
42+
'research',
43+
userToken,
44+
{ executionMode: 'confirm' }
45+
);
46+
check('generate-plan-confirm-mode', Boolean(plan.planId), `planId=${plan.planId}`);
47+
48+
// 2. Execute — confirm mode gates the first step instead of running it.
49+
const exec = await client.executePlan(plan.planId, userToken);
50+
check(
51+
'execute-plan-awaiting-approval',
52+
exec.status === 'awaiting_approval',
53+
`status=${exec.status}`
54+
);
55+
56+
// 3. Resume loop. Every non-terminal resume must carry ALL step-mode HITL
57+
// fields; the terminal resume must carry workflowId + message and none
58+
// of the step-gating fields.
59+
let sawStepGate = false;
60+
let resume = null;
61+
for (let i = 0; i < 25; i++) {
62+
resume = await client.resumePlan(plan.planId, true);
63+
if (resume.status !== 'awaiting_approval') {
64+
break;
65+
}
66+
sawStepGate = true;
67+
check(
68+
`step-gate-${i}-workflow-id`,
69+
typeof resume.workflowId === 'string' && resume.workflowId.length > 0,
70+
`workflowId=${resume.workflowId}`
71+
);
72+
check(
73+
`step-gate-${i}-step-result`,
74+
resume.stepResult !== undefined && resume.stepResult !== null,
75+
`stepResult=${JSON.stringify(resume.stepResult).slice(0, 80)}`
76+
);
77+
check(
78+
`step-gate-${i}-next-step`,
79+
Number.isInteger(resume.nextStep),
80+
`nextStep=${resume.nextStep}`
81+
);
82+
check(
83+
`step-gate-${i}-next-step-name`,
84+
typeof resume.nextStepName === 'string' && resume.nextStepName.length > 0,
85+
`nextStepName=${resume.nextStepName}`
86+
);
87+
check(
88+
`step-gate-${i}-total-steps`,
89+
Number.isInteger(resume.totalSteps) && resume.totalSteps >= 2,
90+
`totalSteps=${resume.totalSteps}`
91+
);
92+
}
93+
94+
// The generated plan must have gated at least one intermediate step —
95+
// otherwise the step-mode wire path was never exercised.
96+
check('at-least-one-step-gate', sawStepGate, `finalStatus=${resume?.status}`);
97+
98+
// 4. Terminal resume: workflow_id + message on the wire, no step gating.
99+
check('terminal-status-completed', resume?.status === 'completed', `status=${resume?.status}`);
100+
check(
101+
'terminal-workflow-id',
102+
typeof resume?.workflowId === 'string' && resume.workflowId.length > 0,
103+
`workflowId=${resume?.workflowId}`
104+
);
105+
check(
106+
'terminal-message',
107+
typeof resume?.message === 'string' && resume.message.length > 0,
108+
`message=${resume?.message}`
109+
);
110+
check(
111+
'terminal-no-next-step',
112+
resume?.nextStep === undefined &&
113+
resume?.nextStepName === undefined &&
114+
resume?.totalSteps === undefined,
115+
`nextStep=${resume?.nextStep} nextStepName=${resume?.nextStepName} totalSteps=${resume?.totalSteps}`
116+
);
117+
} catch (e) {
118+
check('resume-plan-confirm-loop', false, e.message);
119+
}
120+
121+
if (failures > 0) {
122+
console.log(`RESULT: FAIL (${failures})`);
123+
process.exit(1);
124+
}
125+
console.log('RESULT: PASS');

src/client.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2079,9 +2079,16 @@ export class AxonFlow {
20792079
// `result` is the canonical wire-aggregated outcome on resume.
20802080
result: data.result,
20812081
approved: data.approved,
2082-
// `message` kept populated for the back-compat alias; legacy
2083-
// path read this slot historically.
2082+
// Terminal-path outcome summary (e.g. "All steps completed").
20842083
message: data.message,
2084+
workflowId: data.workflow_id,
2085+
// Step/confirm-mode HITL fields — populated when the platform
2086+
// executes the approved step and gates the next one for approval;
2087+
// absent (undefined) on terminal resumes.
2088+
stepResult: data.step_result,
2089+
nextStep: data.next_step,
2090+
nextStepName: data.next_step_name,
2091+
totalSteps: data.total_steps,
20852092
};
20862093
}
20872094

src/types/planning.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,14 @@ export interface PlanVersionsResponse {
202202
/**
203203
* Response from resuming a paused plan.
204204
*
205-
* Wire shape: `{ plan_id, status, result }`. The 5 fields below marked
206-
* @deprecated were declared but never populated by the `resumePlan`
207-
* transformer — they have always read `undefined`. Use `result` and
208-
* `status` for resume outcomes.
205+
* Wire shape (see `ResumePlanResponse` in the community OpenAPI spec and
206+
* the orchestrator's resume handler): `plan_id`, `workflow_id` and
207+
* `status` are always present. On the step/confirm-mode HITL path —
208+
* where the platform executes the approved step and gates the next one
209+
* for approval — the response additionally carries `step_result`,
210+
* `next_step`, `next_step_name` and `total_steps`. On terminal resumes
211+
* (plan completed or rejected) those step-mode fields are absent and
212+
* `message` summarizes the outcome instead.
209213
*/
210214
export interface ResumePlanResponse {
211215
planId: string;
@@ -214,29 +218,40 @@ export interface ResumePlanResponse {
214218
result?: unknown;
215219
approved?: boolean;
216220
/**
217-
* @deprecated Declared on the interface but never populated by the
218-
* `resumePlan` transformer. Always read `undefined`. Removed in v7.
221+
* WCP workflow id the plan is bound to. Surfaced on every resume
222+
* response so callers don't need a separate plan-status round-trip.
219223
*/
220224
workflowId?: string;
221225
/**
222-
* @deprecated Same as `workflowId` — never populated. Removed in v7.
226+
* Human-readable summary of the resume outcome (e.g. "All steps
227+
* completed", "Step rejected, plan aborted"). Populated on terminal
228+
* resume paths; absent when the platform gates the next step.
223229
*/
224230
message?: string;
225231
/**
226-
* @deprecated Never populated; the wire emits `result`. Use `result`.
227-
* Removed in v7.
232+
* Result of the step that was waiting on this approval. Populated on
233+
* the step/confirm-mode HITL resume path; shape depends on the step
234+
* type, so treat it as opaque unless the step type is known. Absent
235+
* on terminal resumes that executed no step.
228236
*/
229237
stepResult?: Record<string, any>;
230238
/**
231-
* @deprecated Never populated; not on the wire. Removed in v7.
239+
* Index of the next step the orchestrator gated for approval.
240+
* Populated on the step/confirm-mode HITL resume path when the
241+
* platform pauses before the next step (`status: 'awaiting_approval'`);
242+
* absent on terminal resumes.
232243
*/
233244
nextStep?: number;
234245
/**
235-
* @deprecated Never populated; not on the wire. Removed in v7.
246+
* Name of the step at `nextStep` (mirrors PlanStep.name). Populated
247+
* alongside `nextStep` on the step/confirm-mode HITL resume path;
248+
* absent on terminal resumes.
236249
*/
237250
nextStepName?: string;
238251
/**
239-
* @deprecated Never populated; not on the wire. Removed in v7.
252+
* Total number of steps in the plan, so callers can render
253+
* "Step N of M" without a separate plan-status lookup. Populated on
254+
* the step/confirm-mode HITL resume path; absent on terminal resumes.
240255
*/
241256
totalSteps?: number;
242257
}

tests/fixtures/wire-shape-baseline.json

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@
392392
"spec_only": []
393393
},
394394
"MCPCheckOutputResponse": {
395-
"_note": "SDK-superset (acknowledged): the platform emits redaction_evaluated on check-output since enterprise #2865/#2866 (2026-07-09); the spec declaration rides the v9.7.0 community sync. Flip (drop this entry) on the next pin bump. refresh.js does not preserve _note keys re-add on regeneration.",
395+
"_note": "SDK-superset (acknowledged): the platform emits redaction_evaluated on check-output since enterprise #2865/#2866 (2026-07-09); the spec declaration rides the v9.7.0 community sync. Flip (drop this entry) on the next pin bump. refresh.js does not preserve _note keys \u2014 re-add on regeneration.",
396396
"sdk_only": [
397397
"redaction_evaluated"
398398
],
@@ -426,19 +426,6 @@
426426
],
427427
"spec_only": []
428428
},
429-
"ResumePlanResponse": {
430-
"_note": "Real drift, spec side new in this pin: v9.6.1 declares next_step/next_step_name/step_result on the wire, but the SDK's resumePlan transformer does not populate them (its camelCase nextStep/nextStepName/stepResult slots are documented-deprecated and never populated). Burn down via a dedicated SDK fix PR, not this pin bump.",
431-
"sdk_only": [
432-
"nextStep",
433-
"nextStepName",
434-
"stepResult"
435-
],
436-
"spec_only": [
437-
"next_step",
438-
"next_step_name",
439-
"step_result"
440-
]
441-
},
442429
"StaticPolicy": {
443430
"sdk_only": [
444431
"hasOverride"

tests/wcp-approval-webhook-rollback.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,108 @@ describe('WCP Approval, Rollback, and Webhook Methods', () => {
328328
});
329329
});
330330

331+
// ==========================================================================
332+
// Plan Resume Tests (step/confirm-mode HITL + terminal paths)
333+
// ==========================================================================
334+
335+
describe('resumePlan', () => {
336+
it('should surface step-mode HITL fields when the platform gates the next step', async () => {
337+
// Wire shape from the orchestrator's confirm/step-mode resume path:
338+
// the approved step ran, the next step is gated for approval.
339+
const resumeResponse = {
340+
plan_id: 'plan_123',
341+
workflow_id: 'wf_456',
342+
status: 'awaiting_approval',
343+
step_result: { output: 'step 1 done', records: 3 },
344+
next_step: 2,
345+
next_step_name: 'send_notification',
346+
total_steps: 4,
347+
};
348+
349+
mockFetch.mockResolvedValueOnce(mockResponse(resumeResponse));
350+
351+
const result = await client.resumePlan('plan_123', true);
352+
353+
expect(result.planId).toBe('plan_123');
354+
expect(result.workflowId).toBe('wf_456');
355+
expect(result.status).toBe('awaiting_approval');
356+
expect(result.stepResult).toEqual({ output: 'step 1 done', records: 3 });
357+
expect(result.nextStep).toBe(2);
358+
expect(result.nextStepName).toBe('send_notification');
359+
expect(result.totalSteps).toBe(4);
360+
expect(mockFetch).toHaveBeenCalledWith(
361+
'http://localhost:8080/api/v1/plan/plan_123/resume',
362+
expect.objectContaining({
363+
method: 'POST',
364+
body: JSON.stringify({ approved: true }),
365+
})
366+
);
367+
});
368+
369+
it('should map the terminal completed path (message, no step-gating fields)', async () => {
370+
const resumeResponse = {
371+
plan_id: 'plan_123',
372+
workflow_id: 'wf_456',
373+
status: 'completed',
374+
step_result: { output: 'final step done' },
375+
message: 'All steps completed',
376+
};
377+
378+
mockFetch.mockResolvedValueOnce(mockResponse(resumeResponse));
379+
380+
const result = await client.resumePlan('plan_123');
381+
382+
expect(result.planId).toBe('plan_123');
383+
expect(result.workflowId).toBe('wf_456');
384+
expect(result.status).toBe('completed');
385+
expect(result.message).toBe('All steps completed');
386+
expect(result.stepResult).toEqual({ output: 'final step done' });
387+
// No next step is gated on a terminal resume.
388+
expect(result.nextStep).toBeUndefined();
389+
expect(result.nextStepName).toBeUndefined();
390+
expect(result.totalSteps).toBeUndefined();
391+
// approved defaults to true on the request wire.
392+
expect(mockFetch).toHaveBeenCalledWith(
393+
'http://localhost:8080/api/v1/plan/plan_123/resume',
394+
expect.objectContaining({
395+
method: 'POST',
396+
body: JSON.stringify({ approved: true }),
397+
})
398+
);
399+
});
400+
401+
it('should map the rejected path and send approved: false', async () => {
402+
const resumeResponse = {
403+
plan_id: 'plan_123',
404+
workflow_id: 'wf_456',
405+
status: 'rejected',
406+
message: 'Step rejected, plan aborted',
407+
};
408+
409+
mockFetch.mockResolvedValueOnce(mockResponse(resumeResponse));
410+
411+
const result = await client.resumePlan('plan_123', false);
412+
413+
expect(result.status).toBe('rejected');
414+
expect(result.message).toBe('Step rejected, plan aborted');
415+
expect(result.workflowId).toBe('wf_456');
416+
expect(result.stepResult).toBeUndefined();
417+
expect(mockFetch).toHaveBeenCalledWith(
418+
'http://localhost:8080/api/v1/plan/plan_123/resume',
419+
expect.objectContaining({
420+
method: 'POST',
421+
body: JSON.stringify({ approved: false }),
422+
})
423+
);
424+
});
425+
426+
it('should throw PlanExecutionError on failure', async () => {
427+
mockFetch.mockResolvedValueOnce(mockResponse({ error: 'Plan is not executing' }, 400));
428+
429+
await expect(client.resumePlan('plan_123')).rejects.toThrow('Plan resume failed');
430+
});
431+
});
432+
331433
// ==========================================================================
332434
// Webhook CRUD Tests (Feature 7)
333435
// ==========================================================================

0 commit comments

Comments
 (0)