Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,24 @@ Hostile-testing sweep ahead of the BukuWarung integration
read `AXONFLOW_USER_TOKEN`, thread it through `generatePlan`/`executePlan`
and `queryConnector`, and set a non-zero exit code on unexpected failures
(`PolicyViolationError` remains an expected demo outcome).
- **`resumePlan` surfaces the step-mode HITL fields the platform emits when
gating the next step** — `next_step`, `next_step_name`, `step_result`,
`total_steps` and `workflow_id` now map onto `ResumePlanResponse`
(`nextStep`/`nextStepName`/`stepResult`/`totalSteps`/`workflowId`). The
transformer dropped them and the type doc wrongly marked them
never-populated/deprecated; the orchestrator's confirm/step-mode resume
path has emitted them all along. Their `ResumePlanResponse` entries drop
out of the wire-shape drift baseline.

### Added

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

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

Expand Down
125 changes: 125 additions & 0 deletions runtime-e2e/resume_plan_step_fields/test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// runtime-e2e/resume_plan_step_fields/test.mjs
// Real-stack assertion: resumePlan surfaces the step/confirm-mode HITL fields
// the orchestrator emits when it gates the next step (step_result, next_step,
// next_step_name, total_steps) plus workflow_id on every resume path and
// message on terminal paths. Pre-fix, the transformer dropped all of them and
// the ResumePlanResponse doc marked them "never populated".
// Per CLAUDE.md HARD RULE #0 — this test MUST hit a real agent, no mocks.
//
// Drives the real confirm-mode loop end-to-end:
// generatePlan(executionMode: 'confirm') → executePlan (WCP gates step 0,
// awaiting_approval) → resumePlan(approved) per step → terminal resume.
//
// Requires an Enterprise stack (confirm/step mode is license-gated).
// Run (after `source /tmp/axonflow-e2e-env.sh` from the enterprise setup
// script; npm run build first):
//
// AXONFLOW_AGENT_URL=http://localhost:8080 node runtime-e2e/resume_plan_step_fields/test.mjs

import { AxonFlow } from '../../dist/esm/index.js';

const endpoint = process.env.AXONFLOW_AGENT_URL || 'http://localhost:8080';
const clientId = process.env.AXONFLOW_CLIENT_ID;
const clientSecret = process.env.AXONFLOW_CLIENT_SECRET;
const userToken = process.env.AXONFLOW_USER_TOKEN || '';
if (!clientId || !clientSecret) {
console.error('AXONFLOW_CLIENT_ID + AXONFLOW_CLIENT_SECRET must be set; see ../README.md');
process.exit(2);
}

const client = new AxonFlow({ clientId, clientSecret, endpoint, timeout: 120000 });

let failures = 0;
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} [${name}] ${detail}`);
if (!ok) failures++;
};

try {
// 1. Generate a multi-step plan in confirm mode (every step gated).
const plan = await client.generatePlan(
'Plan a two-step research task: gather sources, then summarize findings',
'research',
userToken,
{ executionMode: 'confirm' }
);
check('generate-plan-confirm-mode', Boolean(plan.planId), `planId=${plan.planId}`);

// 2. Execute — confirm mode gates the first step instead of running it.
const exec = await client.executePlan(plan.planId, userToken);
check(
'execute-plan-awaiting-approval',
exec.status === 'awaiting_approval',
`status=${exec.status}`
);

// 3. Resume loop. Every non-terminal resume must carry ALL step-mode HITL
// fields; the terminal resume must carry workflowId + message and none
// of the step-gating fields.
let sawStepGate = false;
let resume = null;
for (let i = 0; i < 25; i++) {
resume = await client.resumePlan(plan.planId, true);
if (resume.status !== 'awaiting_approval') {
break;
}
sawStepGate = true;
check(
`step-gate-${i}-workflow-id`,
typeof resume.workflowId === 'string' && resume.workflowId.length > 0,
`workflowId=${resume.workflowId}`
);
check(
`step-gate-${i}-step-result`,
resume.stepResult !== undefined && resume.stepResult !== null,
`stepResult=${JSON.stringify(resume.stepResult).slice(0, 80)}`
);
check(
`step-gate-${i}-next-step`,
Number.isInteger(resume.nextStep),
`nextStep=${resume.nextStep}`
);
check(
`step-gate-${i}-next-step-name`,
typeof resume.nextStepName === 'string' && resume.nextStepName.length > 0,
`nextStepName=${resume.nextStepName}`
);
check(
`step-gate-${i}-total-steps`,
Number.isInteger(resume.totalSteps) && resume.totalSteps >= 2,
`totalSteps=${resume.totalSteps}`
);
}

// The generated plan must have gated at least one intermediate step —
// otherwise the step-mode wire path was never exercised.
check('at-least-one-step-gate', sawStepGate, `finalStatus=${resume?.status}`);

// 4. Terminal resume: workflow_id + message on the wire, no step gating.
check('terminal-status-completed', resume?.status === 'completed', `status=${resume?.status}`);
check(
'terminal-workflow-id',
typeof resume?.workflowId === 'string' && resume.workflowId.length > 0,
`workflowId=${resume?.workflowId}`
);
check(
'terminal-message',
typeof resume?.message === 'string' && resume.message.length > 0,
`message=${resume?.message}`
);
check(
'terminal-no-next-step',
resume?.nextStep === undefined &&
resume?.nextStepName === undefined &&
resume?.totalSteps === undefined,
`nextStep=${resume?.nextStep} nextStepName=${resume?.nextStepName} totalSteps=${resume?.totalSteps}`
);
} catch (e) {
check('resume-plan-confirm-loop', false, e.message);
}

if (failures > 0) {
console.log(`RESULT: FAIL (${failures})`);
process.exit(1);
}
console.log('RESULT: PASS');
11 changes: 9 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2079,9 +2079,16 @@ export class AxonFlow {
// `result` is the canonical wire-aggregated outcome on resume.
result: data.result,
approved: data.approved,
// `message` kept populated for the back-compat alias; legacy
// path read this slot historically.
// Terminal-path outcome summary (e.g. "All steps completed").
message: data.message,
workflowId: data.workflow_id,
// Step/confirm-mode HITL fields — populated when the platform
// executes the approved step and gates the next one for approval;
// absent (undefined) on terminal resumes.
stepResult: data.step_result,
nextStep: data.next_step,
nextStepName: data.next_step_name,
totalSteps: data.total_steps,
};
}

Expand Down
39 changes: 27 additions & 12 deletions src/types/planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,14 @@ export interface PlanVersionsResponse {
/**
* Response from resuming a paused plan.
*
* Wire shape: `{ plan_id, status, result }`. The 5 fields below marked
* @deprecated were declared but never populated by the `resumePlan`
* transformer — they have always read `undefined`. Use `result` and
* `status` for resume outcomes.
* Wire shape (see `ResumePlanResponse` in the community OpenAPI spec and
* the orchestrator's resume handler): `plan_id`, `workflow_id` and
* `status` are always present. On the step/confirm-mode HITL path —
* where the platform executes the approved step and gates the next one
* for approval — the response additionally carries `step_result`,
* `next_step`, `next_step_name` and `total_steps`. On terminal resumes
* (plan completed or rejected) those step-mode fields are absent and
* `message` summarizes the outcome instead.
*/
export interface ResumePlanResponse {
planId: string;
Expand All @@ -214,29 +218,40 @@ export interface ResumePlanResponse {
result?: unknown;
approved?: boolean;
/**
* @deprecated Declared on the interface but never populated by the
* `resumePlan` transformer. Always read `undefined`. Removed in v7.
* WCP workflow id the plan is bound to. Surfaced on every resume
* response so callers don't need a separate plan-status round-trip.
*/
workflowId?: string;
/**
* @deprecated Same as `workflowId` — never populated. Removed in v7.
* Human-readable summary of the resume outcome (e.g. "All steps
* completed", "Step rejected, plan aborted"). Populated on terminal
* resume paths; absent when the platform gates the next step.
*/
message?: string;
/**
* @deprecated Never populated; the wire emits `result`. Use `result`.
* Removed in v7.
* Result of the step that was waiting on this approval. Populated on
* the step/confirm-mode HITL resume path; shape depends on the step
* type, so treat it as opaque unless the step type is known. Absent
* on terminal resumes that executed no step.
*/
stepResult?: Record<string, any>;
/**
* @deprecated Never populated; not on the wire. Removed in v7.
* Index of the next step the orchestrator gated for approval.
* Populated on the step/confirm-mode HITL resume path when the
* platform pauses before the next step (`status: 'awaiting_approval'`);
* absent on terminal resumes.
*/
nextStep?: number;
/**
* @deprecated Never populated; not on the wire. Removed in v7.
* Name of the step at `nextStep` (mirrors PlanStep.name). Populated
* alongside `nextStep` on the step/confirm-mode HITL resume path;
* absent on terminal resumes.
*/
nextStepName?: string;
/**
* @deprecated Never populated; not on the wire. Removed in v7.
* Total number of steps in the plan, so callers can render
* "Step N of M" without a separate plan-status lookup. Populated on
* the step/confirm-mode HITL resume path; absent on terminal resumes.
*/
totalSteps?: number;
}
Expand Down
15 changes: 1 addition & 14 deletions tests/fixtures/wire-shape-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@
"spec_only": []
},
"MCPCheckOutputResponse": {
"_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.",
"_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.",
"sdk_only": [
"redaction_evaluated"
],
Expand Down Expand Up @@ -426,19 +426,6 @@
],
"spec_only": []
},
"ResumePlanResponse": {
"_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.",
"sdk_only": [
"nextStep",
"nextStepName",
"stepResult"
],
"spec_only": [
"next_step",
"next_step_name",
"step_result"
]
},
"StaticPolicy": {
"sdk_only": [
"hasOverride"
Expand Down
102 changes: 102 additions & 0 deletions tests/wcp-approval-webhook-rollback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,108 @@ describe('WCP Approval, Rollback, and Webhook Methods', () => {
});
});

// ==========================================================================
// Plan Resume Tests (step/confirm-mode HITL + terminal paths)
// ==========================================================================

describe('resumePlan', () => {
it('should surface step-mode HITL fields when the platform gates the next step', async () => {
// Wire shape from the orchestrator's confirm/step-mode resume path:
// the approved step ran, the next step is gated for approval.
const resumeResponse = {
plan_id: 'plan_123',
workflow_id: 'wf_456',
status: 'awaiting_approval',
step_result: { output: 'step 1 done', records: 3 },
next_step: 2,
next_step_name: 'send_notification',
total_steps: 4,
};

mockFetch.mockResolvedValueOnce(mockResponse(resumeResponse));

const result = await client.resumePlan('plan_123', true);

expect(result.planId).toBe('plan_123');
expect(result.workflowId).toBe('wf_456');
expect(result.status).toBe('awaiting_approval');
expect(result.stepResult).toEqual({ output: 'step 1 done', records: 3 });
expect(result.nextStep).toBe(2);
expect(result.nextStepName).toBe('send_notification');
expect(result.totalSteps).toBe(4);
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/plan/plan_123/resume',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ approved: true }),
})
);
});

it('should map the terminal completed path (message, no step-gating fields)', async () => {
const resumeResponse = {
plan_id: 'plan_123',
workflow_id: 'wf_456',
status: 'completed',
step_result: { output: 'final step done' },
message: 'All steps completed',
};

mockFetch.mockResolvedValueOnce(mockResponse(resumeResponse));

const result = await client.resumePlan('plan_123');

expect(result.planId).toBe('plan_123');
expect(result.workflowId).toBe('wf_456');
expect(result.status).toBe('completed');
expect(result.message).toBe('All steps completed');
expect(result.stepResult).toEqual({ output: 'final step done' });
// No next step is gated on a terminal resume.
expect(result.nextStep).toBeUndefined();
expect(result.nextStepName).toBeUndefined();
expect(result.totalSteps).toBeUndefined();
// approved defaults to true on the request wire.
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/plan/plan_123/resume',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ approved: true }),
})
);
});

it('should map the rejected path and send approved: false', async () => {
const resumeResponse = {
plan_id: 'plan_123',
workflow_id: 'wf_456',
status: 'rejected',
message: 'Step rejected, plan aborted',
};

mockFetch.mockResolvedValueOnce(mockResponse(resumeResponse));

const result = await client.resumePlan('plan_123', false);

expect(result.status).toBe('rejected');
expect(result.message).toBe('Step rejected, plan aborted');
expect(result.workflowId).toBe('wf_456');
expect(result.stepResult).toBeUndefined();
expect(mockFetch).toHaveBeenCalledWith(
'http://localhost:8080/api/v1/plan/plan_123/resume',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ approved: false }),
})
);
});

it('should throw PlanExecutionError on failure', async () => {
mockFetch.mockResolvedValueOnce(mockResponse({ error: 'Plan is not executing' }, 400));

await expect(client.resumePlan('plan_123')).rejects.toThrow('Plan resume failed');
});
});

// ==========================================================================
// Webhook CRUD Tests (Feature 7)
// ==========================================================================
Expand Down
Loading