refactor(sdk): collapse plan updates to typed steps (#109660)

* refactor(sdk): collapse plan updates to typed steps

Per owner decision, remove the shipped steps: string[] SDK field and its unreleased planSteps replacement early. Collapse onPlanUpdate to one typed steps field. Retain wire-level string normalization for external Codex plugin version skew.

* chore(sdk): tighten rebased surface budget
This commit is contained in:
Peter Steinberger
2026-07-16 22:35:29 -07:00
committed by GitHub
parent ec42788cef
commit a1dfd47edd
22 changed files with 54 additions and 104 deletions

View File

@@ -167,8 +167,7 @@ type DispatchInboundParams = {
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
steps?: string[];
planSteps?: Array<{ step: string; status: "pending" | "in_progress" | "completed" }>;
steps?: Array<{ step: string; status: "pending" | "in_progress" | "completed" }>;
}) => Promise<void> | void;
onApprovalEvent?: (payload: { phase?: string; command?: string }) => Promise<void> | void;
onCommandOutput?: (payload: {
@@ -3243,8 +3242,7 @@ describe("processDiscordMessage draft streaming", () => {
await params?.replyOptions?.onPlanUpdate?.({
phase: "update",
explanation: "Implementing the change.",
steps: ["Inspect", "Patch", "Test"],
planSteps: [
steps: [
{ step: "Inspect", status: "completed" },
{ step: "Patch", status: "in_progress" },
{ step: "Test", status: "pending" },

View File

@@ -1188,7 +1188,7 @@ async function processDiscordMessageInner(
if (payload.phase !== "update") {
return;
}
await draftPreview.pushPlanProgress(payload.planSteps, {
await draftPreview.pushPlanProgress(payload.steps, {
explanation: payload.explanation,
});
},

View File

@@ -2952,8 +2952,7 @@ describe("matrix monitor handler draft streaming", () => {
onPlanUpdate?: (payload: {
phase: string;
explanation?: string;
steps?: string[];
planSteps?: Array<{ step: string; status: "pending" | "in_progress" | "completed" }>;
steps?: Array<{ step: string; status: "pending" | "in_progress" | "completed" }>;
}) => Promise<void>;
onApprovalEvent?: (payload: { phase: string; command?: string }) => Promise<void>;
onCommandOutput?: (payload: {
@@ -3126,7 +3125,7 @@ describe("matrix monitor handler draft streaming", () => {
await opts.onPlanUpdate?.({
phase: "update",
explanation: "Initial plan",
planSteps: [{ step: "Inspect", status: "in_progress" }],
steps: [{ step: "Inspect", status: "in_progress" }],
});
await vi.waitFor(() => {
expect(singleTextMessageBody()).toBe("`Initial plan`\n\n`▸ Inspect`");
@@ -3135,7 +3134,7 @@ describe("matrix monitor handler draft streaming", () => {
await opts.onPlanUpdate?.({
phase: "update",
explanation: "Revised plan",
planSteps: [
steps: [
{ step: "Inspect", status: "completed" },
{ step: "Patch", status: "in_progress" },
],

View File

@@ -1969,7 +1969,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (payload.phase !== "update") {
return;
}
await pushPlanProgress(payload.planSteps, payload.explanation);
await pushPlanProgress(payload.steps, payload.explanation);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {

View File

@@ -460,7 +460,7 @@ export function createMSTeamsReplyDispatcher(params: {
if (payload?.phase !== "update") {
return;
}
await streamController.pushPlanProgress(normalizeAgentPlanSteps(payload.planSteps), {
await streamController.pushPlanProgress(normalizeAgentPlanSteps(payload.steps), {
explanation: typeof payload.explanation === "string" ? payload.explanation : undefined,
});
},

View File

@@ -105,8 +105,7 @@ let capturedReplyOptions:
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
steps?: string[];
planSteps?: Array<{
steps?: Array<{
step: string;
status: "pending" | "in_progress" | "completed";
}>;
@@ -1106,8 +1105,7 @@ vi.mock("../reply.runtime.js", () => ({
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
steps?: string[];
planSteps?: Array<{
steps?: Array<{
step: string;
status: "pending" | "in_progress" | "completed";
}>;
@@ -1173,8 +1171,7 @@ vi.mock("../reply.runtime.js", () => ({
await params.replyOptions?.onPlanUpdate?.({
phase: entry.phase,
explanation: entry.explanation,
steps: entry.steps.map((step) => step.step),
planSteps: entry.steps,
steps: entry.steps,
});
} else if (entry.kind === "concurrent_items") {
await Promise.all(
@@ -1281,8 +1278,7 @@ vi.mock("../reply.runtime.js", () => ({
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
steps?: string[];
planSteps?: Array<{
steps?: Array<{
step: string;
status: "pending" | "in_progress" | "completed";
}>;
@@ -1334,8 +1330,7 @@ vi.mock("../reply.runtime.js", () => ({
await params.replyOptions?.onPlanUpdate?.({
phase: entry.phase,
explanation: entry.explanation,
steps: entry.steps.map((step) => step.step),
planSteps: entry.steps,
steps: entry.steps,
});
} else if (entry.kind === "concurrent_items") {
await Promise.all(

View File

@@ -2203,7 +2203,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (payload.phase !== "update") {
return;
}
await pushPlanProgress(payload.planSteps, payload.explanation);
await pushPlanProgress(payload.steps, payload.explanation);
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {

View File

@@ -270,7 +270,7 @@ export function createTelegramProgressController(params: {
};
const handlePlanUpdate = async (payload: CallbackPayload<"onPlanUpdate">) => {
if (payload.phase === "update" && canPushToolProgress()) {
await compositor.pushPlanProgress(payload.planSteps, {
await compositor.pushPlanProgress(payload.steps, {
explanation: payload.explanation,
});
}

View File

@@ -37,7 +37,7 @@ describeTelegramDispatch("dispatchTelegramMessage progress-rendering", () => {
await replyOptions?.onPlanUpdate?.({
phase: "update",
explanation: "Implementing the change.",
planSteps: [
steps: [
{ step: "Inspect", status: "completed" },
{ step: "Patch", status: "in_progress" },
{ step: "Test", status: "pending" },
@@ -66,7 +66,7 @@ describeTelegramDispatch("dispatchTelegramMessage progress-rendering", () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
await replyOptions?.onPlanUpdate?.({
phase: "update",
planSteps: [
steps: [
{ step: "Patch", status: "in_progress" },
{ step: "Test", status: "pending" },
],

View File

@@ -235,7 +235,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// Harvest: retired qa-live-transport-scenarios subpath -6.
// +12: typed plan step/status and checklist formatter across channel barrels.
// +8: plan-step ingress union and normalizer across channel barrels.
// +4: dual-field plan payload builder for the steps deprecation window.
// Harvest: retired dual-field plan payload builder -1.
// +12: active plan-step consumers pinned through channel-outbound and mirrors.
// +6: app-guided provider setup types retained by plugin-entry and mirrors.
// +3: widget HTML validation helpers and tool input error.
@@ -258,7 +258,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// tuning constants, and telegram-consumed claim helpers with compat mirrors,
// after harvesting exports orphaned by the split-out WhatsApp adapter (#108656).
// +10: supplemental sender helpers plus host-owned SQLite lease contracts.
8046,
// Harvest: retired dual-field plan payload builder -1.
8045,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
@@ -271,7 +272,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// Harvest: retired qa-live-transport-scenarios subpath -3.
// +4: shared plan checklist formatter across channel barrels.
// +4: plan-step normalizer across channel barrels.
// +4: dual-field plan payload builder for the steps deprecation window.
// Harvest: retired dual-field plan payload builder -1.
// +6: active plan-step helpers pinned through channel-outbound and mirrors.
// +2: widget HTML document detection and size assertion.
// Used-union narrowing of the 31 wildcard barrels.
@@ -289,7 +290,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +9: narrowed drain seam functions and compat mirrors after the
// WhatsApp-split harvest (#108656).
// +3: supplemental sender helpers plus the PluginStateLeaseRunner callback.
4489,
// Harvest: retired dual-field plan payload builder -1.
4488,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
@@ -299,7 +301,6 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +77: five zero-consumer subpaths enter their removal window.
// +9: typed plan exports and formatter through deprecated channel barrels.
// +6: plan-step ingress union and normalizer through deprecated channel barrels.
// +3: dual-field plan payload builder through deprecated channel barrels.
// +8: channel-outbound plan pins mirrored through deprecated barrels.
// Used-union narrowing drops inherited deprecated exports.
// +1: Telegram runner alias retained for plugin SDK compatibility.
@@ -308,7 +309,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
// +1: unified implicit-mention config type through deprecated config-types.
// +24: narrowed drain seam compat mirrors in the channel-message
// deprecation-window barrels (#108656).
3014,
// Harvest: retired dual-field plan payload builder -1; lower-only drift -8.
3005,
env,
),
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -242,14 +242,7 @@ export type GetReplyOptions = {
phase?: string;
title?: string;
explanation?: string;
/**
* @deprecated Shipped pre-2026.8 shape: plain step text without statuses.
* Still populated for existing consumers; migrate to `planSteps` and
* remove with the deprecation window.
*/
steps?: string[];
/** Canonical typed checklist snapshot; replaces `steps`. */
planSteps?: AgentPlanStep[];
steps?: AgentPlanStep[];
source?: string;
}) => Promise<void> | void;
/** Called when an approval becomes pending or resolves. */

View File

@@ -79,8 +79,7 @@ describe("runCliAgentWithLifecycle", () => {
title: "Plan updated",
explanation: undefined,
source: "codex-exec",
steps: ["Inspect", "Patch"],
planSteps: [
steps: [
{ step: "Inspect", status: "completed" },
{ step: "Patch", status: "pending" },
],
@@ -122,8 +121,7 @@ describe("runCliAgentWithLifecycle", () => {
expect(onPlanUpdate).toHaveBeenCalledWith(
expect.objectContaining({
steps: ["Inspect", "Patch"],
planSteps: [
steps: [
{ step: "Inspect", status: "pending" },
{ step: "Patch", status: "pending" },
],

View File

@@ -18,7 +18,7 @@ import {
resolveAgentRunAbortLifecycleFields,
resolveAgentRunErrorLifecycleFields,
} from "../../agents/run-termination.js";
import { buildPlanUpdateStepFields } from "../../channels/streaming.js";
import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
import type { AgentEventPayload } from "../../infra/agent-events.js";
@@ -378,7 +378,7 @@ function createPlanUpdateBridge(params: {
phase: normalizeOptionalString(evt.data.phase),
title: normalizeOptionalString(evt.data.title),
explanation: normalizeOptionalString(evt.data.explanation),
...buildPlanUpdateStepFields(evt.data.steps),
steps: normalizeAgentPlanSteps(evt.data.steps),
source: normalizeOptionalString(evt.data.source),
};
},

View File

@@ -1,7 +1,7 @@
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js";
import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/run/params.js";
import { buildPlanUpdateStepFields } from "../../channels/streaming.js";
import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
import { logVerbose } from "../../globals.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { ReplyPayload } from "../types.js";
@@ -203,7 +203,7 @@ export function createAgentRunEventHandler(params: {
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
explanation: readStringValue(evt.data.explanation),
...buildPlanUpdateStepFields(evt.data.steps),
steps: normalizeAgentPlanSteps(evt.data.steps),
source: readStringValue(evt.data.source),
});
}

View File

@@ -103,8 +103,7 @@ describe("runAgentTurnWithFallback: command events", () => {
phase: "update",
title: "Assistant proposed a plan",
explanation: "Inspect code, patch it, run tests.",
steps: ["Inspect code", "Patch code", "Run tests", "legacy string"],
planSteps: [
steps: [
{ step: "Inspect code", status: "completed" },
{ step: "Patch code", status: "in_progress" },
{ step: "Run tests", status: "pending" },

View File

@@ -63,7 +63,7 @@ describe("dispatchReplyFromConfig", () => {
await opts?.onPlanUpdate?.({
phase: "update",
explanation: "Inspect code.",
planSteps: [{ step: "Patch code", status: "in_progress" }],
steps: [{ step: "Patch code", status: "in_progress" }],
});
await opts?.onApprovalEvent?.({
phase: "requested",

View File

@@ -668,7 +668,7 @@ describe("dispatchReplyFromConfig", () => {
await opts?.onPlanUpdate?.({
phase: "update",
explanation: "Inspect code, patch it, run tests.",
planSteps: [
steps: [
{ step: "Inspect code", status: "completed" },
{ step: "Patch code", status: "in_progress" },
{ step: "Run tests", status: "pending" },
@@ -711,11 +711,11 @@ describe("dispatchReplyFromConfig", () => {
) => {
await opts?.onPlanUpdate?.({
phase: "update",
planSteps: [{ step: "Inspect code", status: "in_progress" }],
steps: [{ step: "Inspect code", status: "in_progress" }],
});
await opts?.onPlanUpdate?.({
phase: "update",
planSteps: [
steps: [
{ step: "Inspect code", status: "completed" },
{ step: "Patch code", status: "in_progress" },
],
@@ -794,7 +794,7 @@ describe("dispatchReplyFromConfig", () => {
await opts?.onPlanUpdate?.({
phase: "update",
explanation: "Inspect code, patch it, run tests.",
planSteps: [
steps: [
{ step: "Inspect code", status: "completed" },
{ step: "Patch code", status: "in_progress" },
{ step: "Run tests", status: "pending" },
@@ -848,7 +848,7 @@ describe("dispatchReplyFromConfig", () => {
await opts?.onPlanUpdate?.({
phase: "update",
explanation: "Inspect code, patch it, run tests.",
planSteps: [
steps: [
{ step: "Inspect code", status: "completed" },
{ step: "Patch code", status: "in_progress" },
{ step: "Run tests", status: "pending" },
@@ -905,7 +905,7 @@ describe("dispatchReplyFromConfig", () => {
await opts?.onPlanUpdate?.({
phase: "update",
explanation: "Inspect code, patch it, run tests.",
planSteps: [
steps: [
{ step: "Inspect code", status: "completed" },
{ step: "Patch code", status: "in_progress" },
{ step: "Run tests", status: "pending" },

View File

@@ -752,10 +752,9 @@ describe("dispatchReplyFromConfig", () => {
) => {
await opts?.onToolStart?.({ name: "exec", phase: "start" });
await opts?.onItemEvent?.({ itemId: "1", kind: "tool", progressText: "running exec" });
// Shipped pre-2026.8 producer shape: plain string steps.
await opts?.onPlanUpdate?.({
phase: "update",
steps: ["Run command"],
steps: ["Run command"] as never,
});
await opts?.onApprovalEvent?.({ phase: "requested", command: "pnpm test" });
await opts?.onCommandOutput?.({ phase: "end", name: "exec", status: "ok", exitCode: 0 });
@@ -793,8 +792,7 @@ describe("dispatchReplyFromConfig", () => {
});
expect(onPlanUpdate).toHaveBeenCalledWith({
phase: "update",
steps: ["Run command"],
planSteps: [{ step: "Run command", status: "pending" }],
steps: [{ step: "Run command", status: "pending" }],
});
expect(onApprovalEvent).toHaveBeenCalledWith({
phase: "requested",

View File

@@ -2314,16 +2314,13 @@ async function dispatchReplyFromConfigInner(
if (isDispatchOperationAborted()) {
return;
}
// External resolvers call this SDK callback directly and may
// send only the shipped string form; normalize once so
// channel forwards and fallback notices see both fields.
const planSteps =
normalizeAgentPlanSteps(payload.planSteps) ??
normalizeAgentPlanSteps(payload.steps);
const steps = normalizeAgentPlanSteps(payload.steps);
const normalized = {
...payload,
steps: planSteps?.map((entry) => entry.step) ?? payload.steps,
planSteps,
phase: payload.phase,
title: payload.title,
explanation: payload.explanation,
steps,
source: payload.source,
};
markProgress();
await waitForPendingDirectBlockReplyDelivery(
@@ -2349,7 +2346,7 @@ async function dispatchReplyFromConfigInner(
}
await sendPlanUpdate({
explanation: normalized.explanation,
steps: planSteps,
steps,
});
},
onApprovalEvent: async (payload) => {

View File

@@ -43,7 +43,7 @@ import {
import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-placement-admission.js";
import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
import { resolveCandidateThinkingLevel } from "../../agents/thinking-runtime.js";
import { buildPlanUpdateStepFields } from "../../channels/streaming.js";
import { normalizeAgentPlanSteps } from "../../channels/streaming.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, updateSessionEntry } from "../../config/sessions/session-accessor.js";
import type { TypingMode } from "../../config/types.js";
@@ -296,7 +296,7 @@ async function forwardFollowupProgressEvent(params: {
phase: readStringValue(evt.data.phase),
title: readStringValue(evt.data.title),
explanation: readStringValue(evt.data.explanation),
...buildPlanUpdateStepFields(evt.data.steps),
steps: normalizeAgentPlanSteps(evt.data.steps),
source: readStringValue(evt.data.source),
});
}

View File

@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildChannelProgressDraftLine,
buildPlanUpdateStepFields,
formatChannelProgressDraftText,
formatPlanChecklistLines,
normalizeAgentPlanSteps,
@@ -115,7 +114,7 @@ describe("mergeChannelProgressDraftLine", () => {
});
describe("normalizeAgentPlanSteps", () => {
it("normalizes legacy strings and typed entries, dropping blanks", () => {
it("normalizes external-plugin string steps and typed entries, dropping blanks", () => {
expect(
normalizeAgentPlanSteps([
"Inspect",
@@ -130,14 +129,6 @@ describe("normalizeAgentPlanSteps", () => {
]);
expect(normalizeAgentPlanSteps(undefined)).toBeUndefined();
});
it("builds both deprecation-window payload fields", () => {
expect(buildPlanUpdateStepFields([{ step: "Inspect", status: "completed" }])).toEqual({
steps: ["Inspect"],
planSteps: [{ step: "Inspect", status: "completed" }],
});
expect(buildPlanUpdateStepFields(undefined)).toEqual({});
});
});
describe("streaming config resolution", () => {

View File

@@ -186,10 +186,6 @@ export type AgentPlanStep = {
status: AgentPlanStepStatus;
};
/**
* Plan-event ingress shape for the SDK deprecation window: shipped producers
* through 2026.7.x sent `steps: string[]`. Remove with the string form.
*/
export type AgentPlanStepInput = AgentPlanStep | string;
function isAgentPlanStepStatus(value: unknown): value is AgentPlanStepStatus {
@@ -197,25 +193,9 @@ function isAgentPlanStepStatus(value: unknown): value is AgentPlanStepStatus {
}
/**
* Normalizes plan-event steps at public ingress boundaries. Legacy string
* steps become pending typed steps; malformed entries are dropped.
* Older installed @openclaw/codex releases emit plan steps as strings.
* Normalize that external-plugin version skew to pending typed steps.
*/
/**
* Builds both plan-step payload fields for `onPlanUpdate` during the SDK
* deprecation window: canonical `planSteps` plus the shipped pre-2026.8
* `steps: string[]` form. Collapse to `planSteps` when the window closes.
*/
export function buildPlanUpdateStepFields(value: unknown): {
steps?: string[];
planSteps?: AgentPlanStep[];
} {
const planSteps = normalizeAgentPlanSteps(value);
if (!planSteps) {
return {};
}
return { steps: planSteps.map((entry) => entry.step), planSteps };
}
export function normalizeAgentPlanSteps(value: unknown): AgentPlanStep[] | undefined {
if (!Array.isArray(value)) {
return undefined;