Skip to content

Commit 8cb9263

Browse files
fix(lobster): keep ordinary run/resume on default flow fields (#102036)
* fix(lobster): keep default flow fields on runner path * fix(lobster): reject incomplete managed resume revisions * fix(lobster): preserve explicit empty managed flow state * fix(lobster): preserve cross-mode validation --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 6f6c3e2 commit 8cb9263

2 files changed

Lines changed: 198 additions & 10 deletions

File tree

extensions/lobster/src/lobster-tool.test.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,139 @@ describe("lobster plugin tool", () => {
110110
expect(approval.resumeToken).toBe("resume-token-1");
111111
});
112112

113+
it("keeps ordinary run on the embedded runner when flow defaults are injected", async () => {
114+
const runner = {
115+
run: vi.fn().mockResolvedValue({
116+
ok: true,
117+
status: "needs_approval",
118+
output: [],
119+
requiresApproval: {
120+
type: "approval_request",
121+
prompt: "Continue?",
122+
items: [],
123+
resumeToken: "resume-token-1",
124+
},
125+
}),
126+
};
127+
const taskFlow = createFakeTaskFlow();
128+
129+
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
130+
const res = await tool.execute("call-default-flow-run", {
131+
action: "run",
132+
pipeline: "noop",
133+
flowStateJson: "{}",
134+
flowExpectedRevision: 0,
135+
});
136+
137+
expect(taskFlow.createManaged).not.toHaveBeenCalled();
138+
expect(runner.run).toHaveBeenCalledWith({
139+
action: "run",
140+
pipeline: "noop",
141+
cwd: process.cwd(),
142+
timeoutMs: 20_000,
143+
maxStdoutBytes: 512_000,
144+
});
145+
const details = requireRecord(res.details, "ordinary run with flow defaults details");
146+
expect(details.status).toBe("needs_approval");
147+
});
148+
149+
it.each([{ flowId: "flow-1" }, { flowExpectedRevision: 1 }])(
150+
"rejects resume-only fields on run before the ordinary fallback",
151+
async (resumeFields) => {
152+
const runner = { run: vi.fn() };
153+
const tool = createLobsterTool(fakeApi(), {
154+
runner,
155+
taskFlow: createFakeTaskFlow(),
156+
});
157+
158+
await expect(
159+
tool.execute("call-run-with-resume-fields", {
160+
action: "run",
161+
pipeline: "noop",
162+
flowStateJson: "{}",
163+
flowExpectedRevision: 0,
164+
...resumeFields,
165+
}),
166+
).rejects.toThrow(/run action does not accept flowId or flowExpectedRevision/);
167+
expect(runner.run).not.toHaveBeenCalled();
168+
},
169+
);
170+
171+
it("keeps ordinary resume on the embedded runner when flow defaults are injected", async () => {
172+
const runner = {
173+
run: vi.fn().mockResolvedValue({
174+
ok: true,
175+
status: "ok",
176+
output: [{ approved: true }],
177+
requiresApproval: null,
178+
}),
179+
};
180+
const taskFlow = createFakeTaskFlow();
181+
182+
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
183+
const res = await tool.execute("call-default-flow-resume", {
184+
action: "resume",
185+
token: "resume-token-1",
186+
approve: true,
187+
flowStateJson: "{}",
188+
flowExpectedRevision: 0,
189+
});
190+
191+
expect(taskFlow.resume).not.toHaveBeenCalled();
192+
expect(runner.run).toHaveBeenCalledWith({
193+
action: "resume",
194+
token: "resume-token-1",
195+
approve: true,
196+
cwd: process.cwd(),
197+
timeoutMs: 20_000,
198+
maxStdoutBytes: 512_000,
199+
});
200+
const details = requireRecord(res.details, "ordinary resume with flow defaults details");
201+
expect(details.ok).toBe(true);
202+
expect(details.status).toBe("ok");
203+
});
204+
205+
it.each([
206+
{ flowControllerId: "tests/lobster" },
207+
{ flowGoal: "Run Lobster workflow" },
208+
{ flowStateJson: '{"lane":"email"}' },
209+
])("rejects run-only fields on resume before the ordinary fallback", async (runFields) => {
210+
const runner = { run: vi.fn() };
211+
const tool = createLobsterTool(fakeApi(), {
212+
runner,
213+
taskFlow: createFakeTaskFlow(),
214+
});
215+
216+
await expect(
217+
tool.execute("call-resume-with-run-fields", {
218+
action: "resume",
219+
token: "resume-token-1",
220+
approve: true,
221+
flowExpectedRevision: 0,
222+
...runFields,
223+
}),
224+
).rejects.toThrow(/resume action does not accept flowControllerId, flowGoal, or flowStateJson/);
225+
expect(runner.run).not.toHaveBeenCalled();
226+
});
227+
228+
it("rejects resume with a non-default flow revision but no flowId", async () => {
229+
const runner = { run: vi.fn() };
230+
const tool = createLobsterTool(fakeApi(), {
231+
runner,
232+
taskFlow: createFakeTaskFlow(),
233+
});
234+
235+
await expect(
236+
tool.execute("call-revision-without-flow-id", {
237+
action: "resume",
238+
token: "resume-token-1",
239+
approve: true,
240+
flowExpectedRevision: 1,
241+
}),
242+
).rejects.toThrow(/flowId required when using managed TaskFlow resume mode/);
243+
expect(runner.run).not.toHaveBeenCalled();
244+
});
245+
113246
it("normalizes numeric string run limits before invoking the runner", async () => {
114247
const runner = {
115248
run: vi.fn().mockResolvedValue({
@@ -203,6 +336,7 @@ describe("lobster plugin tool", () => {
203336
flowControllerId: "tests/lobster",
204337
flowGoal: "Run Lobster workflow",
205338
flowStateJson: '{"lane":"email"}',
339+
flowExpectedRevision: 0,
206340
flowCurrentStep: "run_lobster",
207341
flowWaitingStep: "await_review",
208342
});
@@ -234,6 +368,41 @@ describe("lobster plugin tool", () => {
234368
expect(mutation.applied).toBe(true);
235369
});
236370

371+
it("preserves explicit empty flow state in managed TaskFlow run mode", async () => {
372+
const runner = {
373+
run: vi.fn().mockResolvedValue({
374+
ok: true,
375+
status: "ok",
376+
output: [],
377+
requiresApproval: null,
378+
}),
379+
};
380+
const taskFlow = createFakeTaskFlow();
381+
382+
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
383+
await tool.execute("call-managed-run-empty-state", {
384+
action: "run",
385+
pipeline: "noop",
386+
flowControllerId: "tests/lobster",
387+
flowGoal: "Run Lobster workflow",
388+
flowStateJson: "{}",
389+
});
390+
391+
expect(taskFlow.createManaged).toHaveBeenCalledWith({
392+
controllerId: "tests/lobster",
393+
goal: "Run Lobster workflow",
394+
currentStep: "run_lobster",
395+
stateJson: {},
396+
});
397+
expect(runner.run).toHaveBeenCalledWith({
398+
action: "run",
399+
pipeline: "noop",
400+
cwd: process.cwd(),
401+
timeoutMs: 20_000,
402+
maxStdoutBytes: 512_000,
403+
});
404+
});
405+
237406
it("rejects managed TaskFlow params when no bound taskFlow runtime is available", async () => {
238407
const tool = createLobsterTool(fakeApi(), {
239408
runner: { run: vi.fn() },
@@ -284,6 +453,7 @@ describe("lobster plugin tool", () => {
284453
approve: true,
285454
flowId: "flow-1",
286455
flowExpectedRevision: 1,
456+
flowStateJson: "{}",
287457
flowCurrentStep: "resume_lobster",
288458
});
289459

extensions/lobster/src/lobster-tool.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,27 @@ function parseOptionalFlowStateJson(value: unknown): JsonLike | undefined {
9797
if (typeof value !== "string") {
9898
throw new Error("flowStateJson must be a JSON string");
9999
}
100+
const trimmed = value.trim();
101+
if (!trimmed) {
102+
return undefined;
103+
}
100104
try {
101-
return JSON.parse(value) as JsonLike;
105+
return JSON.parse(trimmed) as JsonLike;
102106
} catch {
103107
throw new Error("flowStateJson must be valid JSON");
104108
}
105109
}
106110

111+
function isEmptyJsonObject(value: JsonLike | undefined): boolean {
112+
return (
113+
value !== undefined &&
114+
value !== null &&
115+
typeof value === "object" &&
116+
!Array.isArray(value) &&
117+
Object.keys(value).length === 0
118+
);
119+
}
120+
107121
function parseRunFlowParams(params: Record<string, unknown>): ManagedFlowRunParams | null {
108122
const controllerId = readOptionalTrimmedString(params.flowControllerId, "flowControllerId");
109123
const goal = readOptionalTrimmedString(params.flowGoal, "flowGoal");
@@ -112,20 +126,22 @@ function parseRunFlowParams(params: Record<string, unknown>): ManagedFlowRunPara
112126
const stateJson = parseOptionalFlowStateJson(params.flowStateJson);
113127
const resumeFlowId = readOptionalTrimmedString(params.flowId, "flowId");
114128
const resumeRevision = readOptionalNumber(params.flowExpectedRevision, "flowExpectedRevision");
129+
const stateJsonSignalsRunMode = stateJson !== undefined && !isEmptyJsonObject(stateJson);
130+
131+
if (resumeFlowId !== undefined || (resumeRevision !== undefined && resumeRevision !== 0)) {
132+
throw new Error("run action does not accept flowId or flowExpectedRevision");
133+
}
115134

116135
const hasRunFields =
117136
controllerId !== undefined ||
118137
goal !== undefined ||
119138
currentStep !== undefined ||
120139
waitingStep !== undefined ||
121-
stateJson !== undefined;
140+
stateJsonSignalsRunMode;
122141

123142
if (!hasRunFields) {
124143
return null;
125144
}
126-
if (resumeFlowId !== undefined || resumeRevision !== undefined) {
127-
throw new Error("run action does not accept flowId or flowExpectedRevision");
128-
}
129145
if (!controllerId) {
130146
throw new Error("flowControllerId required when using managed TaskFlow run mode");
131147
}
@@ -151,20 +167,22 @@ function parseResumeFlowParams(params: Record<string, unknown>): ManagedFlowResu
151167
const approve = readOptionalBoolean(params.approve, "approve");
152168
const runControllerId = readOptionalTrimmedString(params.flowControllerId, "flowControllerId");
153169
const runGoal = readOptionalTrimmedString(params.flowGoal, "flowGoal");
154-
const stateJson = params.flowStateJson;
170+
const stateJson = parseOptionalFlowStateJson(params.flowStateJson);
171+
const stateJsonDisallowed = stateJson !== undefined && !isEmptyJsonObject(stateJson);
172+
173+
if (runControllerId !== undefined || runGoal !== undefined || stateJsonDisallowed) {
174+
throw new Error("resume action does not accept flowControllerId, flowGoal, or flowStateJson");
175+
}
155176

156177
const hasResumeFields =
157178
flowId !== undefined ||
158-
expectedRevision !== undefined ||
179+
(expectedRevision !== undefined && expectedRevision !== 0) ||
159180
currentStep !== undefined ||
160181
waitingStep !== undefined;
161182

162183
if (!hasResumeFields) {
163184
return null;
164185
}
165-
if (runControllerId !== undefined || runGoal !== undefined || stateJson !== undefined) {
166-
throw new Error("resume action does not accept flowControllerId, flowGoal, or flowStateJson");
167-
}
168186
if (!flowId) {
169187
throw new Error("flowId required when using managed TaskFlow resume mode");
170188
}

0 commit comments

Comments
 (0)