import { describe, expect, it, vi } from "vitest" ;
import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js" ;
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js" ;
import { createLobsterTool } from "./lobster-tool.js" ;
import { createFakeTaskFlow } from "./taskflow-test-helpers.js" ;
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
return createTestPluginApi({
id: "lobster" ,
name: "lobster" ,
source: "test" ,
runtime: { version: "test" } as any,
resolvePath: (p) => p,
...overrides,
});
}
function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
return {
config: {},
workspaceDir: "/tmp" ,
agentDir: "/tmp" ,
agentId: "main" ,
sessionKey: "main" ,
messageChannel: undefined,
agentAccountId: undefined,
sandboxed: false ,
...overrides,
};
}
describe("lobster plugin tool" , () => {
it("returns the Lobster envelope in details" , async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true ,
status: "ok" ,
output: [{ hello: "world" }],
requiresApproval: null ,
}),
};
const tool = createLobsterTool(fakeApi(), { runner });
const res = await tool.execute("call1" , {
action: "run" ,
pipeline: "noop" ,
timeoutMs: 1000 ,
});
expect(runner.run).toHaveBeenCalledWith({
action: "run" ,
pipeline: "noop" ,
cwd: process.cwd(),
timeoutMs: 1000 ,
maxStdoutBytes: 512 _000 ,
});
expect(res.details).toMatchObject({
ok: true ,
status: "ok" ,
output: [{ hello: "world" }],
requiresApproval: null ,
});
});
it("supports approval envelopes without changing the tool contract" , async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true ,
status: "needs_approval" ,
output: [],
requiresApproval: {
type: "approval_request" ,
prompt: "Send these alerts?" ,
items: [{ id: "alert-1" }],
resumeToken: "resume-token-1" ,
},
}),
};
const tool = createLobsterTool(fakeApi(), { runner });
const res = await tool.execute("call-injected-runner" , {
action: "run" ,
pipeline: "noop" ,
argsJson: '{"since_hours":1}' ,
timeoutMs: 1500 ,
maxStdoutBytes: 4096 ,
});
expect(runner.run).toHaveBeenCalledWith({
action: "run" ,
pipeline: "noop" ,
argsJson: '{"since_hours":1}' ,
cwd: process.cwd(),
timeoutMs: 1500 ,
maxStdoutBytes: 4096 ,
});
expect(res.details).toMatchObject({
ok: true ,
status: "needs_approval" ,
requiresApproval: {
type: "approval_request" ,
prompt: "Send these alerts?" ,
resumeToken: "resume-token-1" ,
},
});
});
it("throws when the runner returns an error envelope" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: {
run: vi.fn().mockResolvedValue({
ok: false ,
error: {
type: "runtime_error" ,
message: "boom" ,
},
}),
},
});
await expect(
tool.execute("call-runner-error" , {
action: "run" ,
pipeline: "noop" ,
}),
).rejects.toThrow("boom" );
});
it("can run through managed TaskFlow mode" , async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true ,
status: "needs_approval" ,
output: [],
requiresApproval: {
type: "approval_request" ,
prompt: "Approve this?" ,
items: [{ id: "item-1" }],
resumeToken: "resume-1" ,
approvalId: "approval-1" ,
},
}),
};
const taskFlow = createFakeTaskFlow();
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
const res = await tool.execute("call-managed-run" , {
action: "run" ,
pipeline: "noop" ,
flowControllerId: "tests/lobster" ,
flowGoal: "Run Lobster workflow" ,
flowStateJson: '{"lane":"email"}' ,
flowCurrentStep: "run_lobster" ,
flowWaitingStep: "await_review" ,
});
expect(taskFlow.createManaged).toHaveBeenCalledWith({
controllerId: "tests/lobster" ,
goal: "Run Lobster workflow" ,
currentStep: "run_lobster" ,
stateJson: { lane: "email" },
});
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
flowId: "flow-1" ,
expectedRevision: 1 ,
currentStep: "await_review" ,
waitJson: {
kind: "lobster_approval" ,
prompt: "Approve this?" ,
items: [{ id: "item-1" }],
resumeToken: "resume-1" ,
approvalId: "approval-1" ,
},
});
expect(res.details).toMatchObject({
ok: true ,
status: "needs_approval" ,
flow: {
flowId: "flow-1" ,
},
mutation: {
applied: true ,
},
});
});
it("rejects managed TaskFlow params when no bound taskFlow runtime is available" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-missing-taskflow" , {
action: "run" ,
pipeline: "noop" ,
flowControllerId: "tests/lobster" ,
flowGoal: "Run Lobster workflow" ,
}),
).rejects.toThrow(/Managed TaskFlow run mode requires a bound taskFlow runtime/);
});
it("rejects invalid flowStateJson in managed TaskFlow mode" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
taskFlow: createFakeTaskFlow(),
});
await expect(
tool.execute("call-invalid-flow-json" , {
action: "run" ,
pipeline: "noop" ,
flowControllerId: "tests/lobster" ,
flowGoal: "Run Lobster workflow" ,
flowStateJson: "{bad" ,
}),
).rejects.toThrow(/flowStateJson must be valid JSON/);
});
it("can resume managed TaskFlow mode with only approvalId" , async () => {
const runner = {
run: vi.fn().mockResolvedValue({
ok: true ,
status: "ok" ,
output: [],
requiresApproval: null ,
}),
};
const taskFlow = createFakeTaskFlow();
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
const res = await tool.execute("call-managed-resume-approval-id" , {
action: "resume" ,
approvalId: "approval-1" ,
approve: true ,
flowId: "flow-1" ,
flowExpectedRevision: 1 ,
flowCurrentStep: "resume_lobster" ,
});
expect(taskFlow.resume).toHaveBeenCalledWith({
flowId: "flow-1" ,
expectedRevision: 1 ,
status: "running" ,
currentStep: "resume_lobster" ,
});
expect(runner.run).toHaveBeenCalledWith({
action: "resume" ,
approvalId: "approval-1" ,
approve: true ,
cwd: process.cwd(),
timeoutMs: 20 _000 ,
maxStdoutBytes: 512 _000 ,
});
expect(res.details).toMatchObject({
ok: true ,
status: "ok" ,
mutation: {
applied: true ,
},
});
});
it("rejects managed TaskFlow resume mode without a token or approvalId" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
taskFlow: createFakeTaskFlow(),
});
await expect(
tool.execute("call-missing-resume-token" , {
action: "resume" ,
flowId: "flow-1" ,
flowExpectedRevision: 1 ,
approve: true ,
}),
).rejects.toThrow(/token or approvalId required when using managed TaskFlow resume mode/);
});
it("rejects managed TaskFlow resume mode without approve" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
taskFlow: createFakeTaskFlow(),
});
await expect(
tool.execute("call-missing-resume-approve" , {
action: "resume" ,
token: "resume-token" ,
flowId: "flow-1" ,
flowExpectedRevision: 1 ,
}),
).rejects.toThrow(/approve required when using managed TaskFlow resume mode/);
});
it("requires action" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(tool.execute("call-action-missing" , {})).rejects.toThrow(/action required/);
});
it("rejects unknown action" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-action-unknown" , {
action: "explode" ,
}),
).rejects.toThrow(/Unknown action/);
});
it("rejects absolute cwd" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-absolute-cwd" , {
action: "run" ,
pipeline: "noop" ,
cwd: "/tmp" ,
}),
).rejects.toThrow(/cwd must be a relative path/);
});
it("rejects cwd that escapes the gateway working directory" , async () => {
const tool = createLobsterTool(fakeApi(), {
runner: { run: vi.fn() },
});
await expect(
tool.execute("call-escape-cwd" , {
action: "run" ,
pipeline: "noop" ,
cwd: "../../etc" ,
}),
).rejects.toThrow(/must stay within/);
});
it("can be gated off in sandboxed contexts" , async () => {
const api = fakeApi();
const factoryTool = (ctx: OpenClawPluginToolContext) => {
if (ctx.sandboxed) {
return null ;
}
return createLobsterTool(api, {
runner: { run: vi.fn() },
});
};
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster" );
});
});
Messung V0.5 in Prozent C=100 H=100 G=100
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland