mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 07:11:25 +00:00
Revert "Add mesh auto-planning with chat command UX and hardened auth/session behavior"
This reverts commit 16e59b26a6.
# Conflicts:
# src/auto-reply/reply/commands-mesh.ts
# src/gateway/server-methods/mesh.ts
# src/gateway/server-methods/server-methods.test.ts
This commit is contained in:
@@ -5,7 +5,6 @@ import type { GatewayRequestContext } from "./types.js";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
agent: vi.fn(),
|
||||
agentWait: vi.fn(),
|
||||
agentCommand: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./agent.js", () => ({
|
||||
@@ -15,10 +14,6 @@ vi.mock("./agent.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/agent.js", () => ({
|
||||
agentCommand: (...args: unknown[]) => mocks.agentCommand(...args),
|
||||
}));
|
||||
|
||||
const makeContext = (): GatewayRequestContext =>
|
||||
({
|
||||
dedupe: new Map(),
|
||||
@@ -43,7 +38,6 @@ afterEach(() => {
|
||||
__resetMeshRunsForTest();
|
||||
mocks.agent.mockReset();
|
||||
mocks.agentWait.mockReset();
|
||||
mocks.agentCommand.mockReset();
|
||||
});
|
||||
|
||||
describe("mesh handlers", () => {
|
||||
@@ -147,86 +141,4 @@ describe("mesh handlers", () => {
|
||||
const statusPayload = statusRes.payload as { status: string };
|
||||
expect(statusPayload.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("auto planner creates multiple steps from llm json output", async () => {
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
steps: [
|
||||
{ id: "analyze", prompt: "Analyze requirements" },
|
||||
{ id: "build", prompt: "Build implementation", dependsOn: ["analyze"] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const res = await callMesh("mesh.plan.auto", {
|
||||
goal: "Create dashboard with auth",
|
||||
maxSteps: 4,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as {
|
||||
source: string;
|
||||
plan: { steps: Array<{ id: string }> };
|
||||
order: string[];
|
||||
};
|
||||
expect(payload.source).toBe("llm");
|
||||
expect(payload.plan.steps.map((s) => s.id)).toEqual(["analyze", "build"]);
|
||||
expect(payload.order).toEqual(["analyze", "build"]);
|
||||
expect(mocks.agentCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:mesh-planner",
|
||||
}),
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("auto planner falls back to single-step plan when llm output is invalid", async () => {
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [{ text: "not valid json" }],
|
||||
meta: {},
|
||||
});
|
||||
const res = await callMesh("mesh.plan.auto", {
|
||||
goal: "Do a thing",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const payload = res.payload as {
|
||||
source: string;
|
||||
plan: { steps: Array<{ id: string; prompt: string }> };
|
||||
};
|
||||
expect(payload.source).toBe("fallback");
|
||||
expect(payload.plan.steps).toHaveLength(1);
|
||||
expect(payload.plan.steps[0]?.prompt).toBe("Do a thing");
|
||||
});
|
||||
|
||||
it("auto planner respects caller-provided planner session key", async () => {
|
||||
mocks.agentCommand.mockResolvedValue({
|
||||
payloads: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
steps: [{ id: "one", prompt: "One" }],
|
||||
}),
|
||||
},
|
||||
],
|
||||
meta: {},
|
||||
});
|
||||
|
||||
const res = await callMesh("mesh.plan.auto", {
|
||||
goal: "Do a thing",
|
||||
sessionKey: "agent:main:custom-planner",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.agentCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey: "agent:main:custom-planner",
|
||||
}),
|
||||
expect.any(Object),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { agentCommand } from "../../commands/agent.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateMeshPlanAutoParams,
|
||||
validateMeshPlanParams,
|
||||
validateMeshRetryParams,
|
||||
validateMeshRunParams,
|
||||
validateMeshStatusParams,
|
||||
type MeshRunParams,
|
||||
type MeshWorkflowPlan,
|
||||
} from "../protocol/index.js";
|
||||
import { agentHandlers } from "./agent.js";
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
type MeshStepStatus = "pending" | "running" | "succeeded" | "failed" | "skipped";
|
||||
type MeshRunStatus = "pending" | "running" | "completed" | "failed";
|
||||
@@ -51,51 +48,20 @@ type MeshRunRecord = {
|
||||
history: Array<{ ts: number; type: string; stepId?: string; data?: Record<string, unknown> }>;
|
||||
};
|
||||
|
||||
type MeshAutoStep = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
dependsOn?: string[];
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
thinking?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type MeshAutoPlanShape = {
|
||||
steps?: MeshAutoStep[];
|
||||
};
|
||||
|
||||
const meshRuns = new Map<string, MeshRunRecord>();
|
||||
const MAX_KEEP_RUNS = 200;
|
||||
const AUTO_PLAN_TIMEOUT_MS = 90_000;
|
||||
const PLANNER_MAIN_KEY = "mesh-planner";
|
||||
|
||||
function trimMap() {
|
||||
if (meshRuns.size <= MAX_KEEP_RUNS) {
|
||||
return;
|
||||
}
|
||||
const sorted = [...meshRuns.values()].toSorted((a, b) => a.startedAt - b.startedAt);
|
||||
const sorted = [...meshRuns.values()].sort((a, b) => a.startedAt - b.startedAt);
|
||||
const overflow = meshRuns.size - MAX_KEEP_RUNS;
|
||||
for (const stale of sorted.slice(0, overflow)) {
|
||||
meshRuns.delete(stale.runId);
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyUnknown(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDependsOn(dependsOn: string[] | undefined): string[] {
|
||||
if (!Array.isArray(dependsOn)) {
|
||||
return [];
|
||||
@@ -135,7 +101,19 @@ function normalizePlan(plan: MeshWorkflowPlan): MeshWorkflowPlan {
|
||||
};
|
||||
}
|
||||
|
||||
function createPlanFromParams(params: { goal: string; steps?: MeshAutoStep[] }): MeshWorkflowPlan {
|
||||
function createPlanFromParams(params: {
|
||||
goal: string;
|
||||
steps?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
prompt: string;
|
||||
dependsOn?: string[];
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
thinking?: string;
|
||||
timeoutMs?: number;
|
||||
}>;
|
||||
}): MeshWorkflowPlan {
|
||||
const now = Date.now();
|
||||
const goal = params.goal.trim();
|
||||
const sourceSteps = params.steps?.length
|
||||
@@ -173,9 +151,7 @@ function createPlanFromParams(params: { goal: string; steps?: MeshAutoStep[] }):
|
||||
};
|
||||
}
|
||||
|
||||
function validatePlanGraph(
|
||||
plan: MeshWorkflowPlan,
|
||||
): { ok: true; order: string[] } | { ok: false; error: string } {
|
||||
function validatePlanGraph(plan: MeshWorkflowPlan): { ok: true; order: string[] } | { ok: false; error: string } {
|
||||
const ids = new Set<string>();
|
||||
for (const step of plan.steps) {
|
||||
if (ids.has(step.id)) {
|
||||
@@ -242,12 +218,7 @@ async function callGatewayHandler(
|
||||
): Promise<{ ok: boolean; payload?: unknown; error?: unknown; meta?: Record<string, unknown> }> {
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: {
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
meta?: Record<string, unknown>;
|
||||
}) => {
|
||||
const settle = (result: { ok: boolean; payload?: unknown; error?: unknown; meta?: Record<string, unknown> }) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
@@ -328,7 +299,7 @@ async function executeStep(params: {
|
||||
if (!accepted.ok) {
|
||||
step.status = "failed";
|
||||
step.endedAt = Date.now();
|
||||
step.error = stringifyUnknown(accepted.error ?? "agent request failed");
|
||||
step.error = String(accepted.error ?? "agent request failed");
|
||||
run.history.push({
|
||||
ts: Date.now(),
|
||||
type: "step.error",
|
||||
@@ -385,7 +356,7 @@ async function executeStep(params: {
|
||||
step.error =
|
||||
typeof waitPayload?.error === "string"
|
||||
? waitPayload.error
|
||||
: stringifyUnknown(waited.error ?? `agent.wait returned status ${waitStatus}`);
|
||||
: String(waited.error ?? `agent.wait returned status ${waitStatus}`);
|
||||
run.history.push({
|
||||
ts: Date.now(),
|
||||
type: "step.error",
|
||||
@@ -460,7 +431,6 @@ async function runWorkflow(run: MeshRunRecord, opts: GatewayRequestHandlerOption
|
||||
|
||||
const inFlight = new Set<Promise<void>>();
|
||||
let stopScheduling = false;
|
||||
|
||||
while (true) {
|
||||
const failed = Object.values(run.steps).some((step) => step.status === "failed");
|
||||
if (failed && !run.continueOnError) {
|
||||
@@ -489,7 +459,6 @@ async function runWorkflow(run: MeshRunRecord, opts: GatewayRequestHandlerOption
|
||||
if (pending.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const step of pending) {
|
||||
step.status = "skipped";
|
||||
step.endedAt = Date.now();
|
||||
@@ -578,130 +547,6 @@ function summarizeRun(run: MeshRunRecord) {
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextFromAgentResult(result: unknown): string {
|
||||
const payloads = (result as { payloads?: Array<{ text?: unknown }> } | undefined)?.payloads;
|
||||
if (!Array.isArray(payloads)) {
|
||||
return "";
|
||||
}
|
||||
const texts: string[] = [];
|
||||
for (const payload of payloads) {
|
||||
if (typeof payload?.text === "string" && payload.text.trim()) {
|
||||
texts.push(payload.text.trim());
|
||||
}
|
||||
}
|
||||
return texts.join("\n\n");
|
||||
}
|
||||
|
||||
function parseJsonObjectFromText(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
// keep trying
|
||||
}
|
||||
|
||||
const fenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
if (fenceMatch?.[1]) {
|
||||
try {
|
||||
const parsed = JSON.parse(fenceMatch[1]);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
// keep trying
|
||||
}
|
||||
}
|
||||
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start >= 0 && end > start) {
|
||||
const candidate = trimmed.slice(start, end + 1);
|
||||
try {
|
||||
const parsed = JSON.parse(candidate);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildAutoPlannerPrompt(params: { goal: string; maxSteps: number }) {
|
||||
return [
|
||||
"You are a workflow planner. Convert the user's goal into executable workflow steps.",
|
||||
"Return STRICT JSON only, no markdown, no prose.",
|
||||
'JSON schema: {"steps": [{"id": string, "name"?: string, "prompt": string, "dependsOn"?: string[]}]}',
|
||||
"Rules:",
|
||||
`- Use 2 to ${params.maxSteps} steps.`,
|
||||
"- Keep ids short, lowercase, kebab-case.",
|
||||
"- dependsOn must reference earlier step ids when needed.",
|
||||
"- prompts must be concrete and executable by an AI coding assistant.",
|
||||
"- Do not include extra fields.",
|
||||
`Goal: ${params.goal}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function generateAutoPlan(params: {
|
||||
goal: string;
|
||||
maxSteps: number;
|
||||
agentId?: string;
|
||||
sessionKey?: string;
|
||||
thinking?: string;
|
||||
timeoutMs?: number;
|
||||
lane?: string;
|
||||
opts: GatewayRequestHandlerOptions;
|
||||
}): Promise<{ plan: MeshWorkflowPlan; source: "llm" | "fallback"; plannerText?: string }> {
|
||||
const prompt = buildAutoPlannerPrompt({ goal: params.goal, maxSteps: params.maxSteps });
|
||||
const timeoutSeconds = Math.ceil((params.timeoutMs ?? AUTO_PLAN_TIMEOUT_MS) / 1000);
|
||||
const resolvedAgentId = normalizeAgentId(params.agentId ?? "main");
|
||||
const plannerSessionKey =
|
||||
params.sessionKey?.trim() || `agent:${resolvedAgentId}:${PLANNER_MAIN_KEY}`;
|
||||
|
||||
try {
|
||||
const runResult = await agentCommand(
|
||||
{
|
||||
message: prompt,
|
||||
deliver: false,
|
||||
timeout: String(timeoutSeconds),
|
||||
agentId: resolvedAgentId,
|
||||
sessionKey: plannerSessionKey,
|
||||
...(params.thinking ? { thinking: params.thinking } : {}),
|
||||
...(params.lane ? { lane: params.lane } : {}),
|
||||
},
|
||||
defaultRuntime,
|
||||
params.opts.context.deps,
|
||||
);
|
||||
|
||||
const text = extractTextFromAgentResult(runResult);
|
||||
const parsed = parseJsonObjectFromText(text) as MeshAutoPlanShape | null;
|
||||
const rawSteps = Array.isArray(parsed?.steps) ? parsed.steps : [];
|
||||
if (rawSteps.length > 0) {
|
||||
const plan = normalizePlan(
|
||||
createPlanFromParams({
|
||||
goal: params.goal,
|
||||
steps: rawSteps.slice(0, params.maxSteps),
|
||||
}),
|
||||
);
|
||||
return { plan, source: "llm", plannerText: text };
|
||||
}
|
||||
|
||||
const fallbackPlan = normalizePlan(createPlanFromParams({ goal: params.goal }));
|
||||
return { plan: fallbackPlan, source: "fallback", plannerText: text };
|
||||
} catch {
|
||||
const fallbackPlan = normalizePlan(createPlanFromParams({ goal: params.goal }));
|
||||
return { plan: fallbackPlan, source: "fallback" };
|
||||
}
|
||||
}
|
||||
|
||||
export const meshHandlers: GatewayRequestHandlers = {
|
||||
"mesh.plan": ({ params, respond }) => {
|
||||
if (!validateMeshPlanParams(params)) {
|
||||
@@ -736,56 +581,6 @@ export const meshHandlers: GatewayRequestHandlers = {
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"mesh.plan.auto": async ({ params, respond, ...rest }) => {
|
||||
if (!validateMeshPlanAutoParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid mesh.plan.auto params: ${formatValidationErrors(validateMeshPlanAutoParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const p = params;
|
||||
const maxSteps =
|
||||
typeof p.maxSteps === "number" && Number.isFinite(p.maxSteps)
|
||||
? Math.max(1, Math.min(16, Math.floor(p.maxSteps)))
|
||||
: 6;
|
||||
const auto = await generateAutoPlan({
|
||||
goal: p.goal,
|
||||
maxSteps,
|
||||
agentId: p.agentId,
|
||||
sessionKey: p.sessionKey,
|
||||
thinking: p.thinking,
|
||||
timeoutMs: p.timeoutMs,
|
||||
lane: p.lane,
|
||||
opts: {
|
||||
...rest,
|
||||
params,
|
||||
respond,
|
||||
},
|
||||
});
|
||||
|
||||
const graph = validatePlanGraph(auto.plan);
|
||||
if (!graph.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, graph.error));
|
||||
return;
|
||||
}
|
||||
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
plan: auto.plan,
|
||||
order: graph.order,
|
||||
source: auto.source,
|
||||
plannerText: auto.plannerText,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"mesh.run": async (opts) => {
|
||||
const { params, respond } = opts;
|
||||
if (!validateMeshRunParams(params)) {
|
||||
@@ -799,7 +594,7 @@ export const meshHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const p = params;
|
||||
const p = params as MeshRunParams;
|
||||
const plan = normalizePlan(p.plan);
|
||||
const graph = validatePlanGraph(plan);
|
||||
if (!graph.ok) {
|
||||
@@ -845,7 +640,7 @@ export const meshHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
const run = meshRuns.get(params.runId.trim());
|
||||
if (!run) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "mesh run not found"));
|
||||
respond(false, undefined, errorShape(ErrorCodes.NOT_FOUND, "mesh run not found"));
|
||||
return;
|
||||
}
|
||||
respond(true, summarizeRun(run), undefined);
|
||||
@@ -866,15 +661,11 @@ export const meshHandlers: GatewayRequestHandlers = {
|
||||
const runId = params.runId.trim();
|
||||
const run = meshRuns.get(runId);
|
||||
if (!run) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "mesh run not found"));
|
||||
respond(false, undefined, errorShape(ErrorCodes.NOT_FOUND, "mesh run not found"));
|
||||
return;
|
||||
}
|
||||
if (run.status === "running") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "mesh run is currently running"),
|
||||
);
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "mesh run is currently running"));
|
||||
return;
|
||||
}
|
||||
const stepIds = resolveStepIdsForRetry(run, params.stepIds);
|
||||
|
||||
@@ -25,17 +25,6 @@ type HealthStatusHandlerParams = Parameters<
|
||||
>[0];
|
||||
|
||||
describe("waitForAgentJob", () => {
|
||||
const AGENT_RUN_ERROR_RETRY_GRACE_MS = 15_000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("maps lifecycle end events with aborted=true to timeout", async () => {
|
||||
const runId = `run-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const waitPromise = waitForAgentJob({ runId, timeoutMs: 1_000 });
|
||||
@@ -67,86 +56,6 @@ describe("waitForAgentJob", () => {
|
||||
expect(snapshot?.startedAt).toBe(300);
|
||||
expect(snapshot?.endedAt).toBe(400);
|
||||
});
|
||||
|
||||
it("treats transient error->start->end as recovered when restart lands inside grace", async () => {
|
||||
const runId = `run-recover-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const waitPromise = waitForAgentJob({ runId, timeoutMs: 60_000 });
|
||||
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 100 } });
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "error", endedAt: 110, error: "transient" },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 200 } });
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end", endedAt: 260 } });
|
||||
|
||||
const snapshot = await waitPromise;
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot?.status).toBe("ok");
|
||||
expect(snapshot?.startedAt).toBe(200);
|
||||
expect(snapshot?.endedAt).toBe(260);
|
||||
});
|
||||
|
||||
it("resolves error only after grace expires when no recovery start arrives", async () => {
|
||||
const runId = `run-error-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const waitPromise = waitForAgentJob({ runId, timeoutMs: 60_000 });
|
||||
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 10 } });
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "error", endedAt: 20, error: "fatal" },
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
void waitPromise.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AGENT_RUN_ERROR_RETRY_GRACE_MS - 1);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const snapshot = await waitPromise;
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot?.status).toBe("error");
|
||||
expect(snapshot?.error).toBe("fatal");
|
||||
expect(snapshot?.startedAt).toBe(10);
|
||||
expect(snapshot?.endedAt).toBe(20);
|
||||
});
|
||||
|
||||
it("honors pending error grace when waiter attaches after the error event", async () => {
|
||||
const runId = `run-late-wait-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "start", startedAt: 900 } });
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "error", endedAt: 999, error: "late-listener" },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
const waitPromise = waitForAgentJob({ runId, timeoutMs: 60_000 });
|
||||
let settled = false;
|
||||
void waitPromise.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AGENT_RUN_ERROR_RETRY_GRACE_MS - 5_001);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
const snapshot = await waitPromise;
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot?.status).toBe("error");
|
||||
expect(snapshot?.error).toBe("late-listener");
|
||||
expect(snapshot?.startedAt).toBe(900);
|
||||
expect(snapshot?.endedAt).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectTimestamp", () => {
|
||||
@@ -331,12 +240,10 @@ describe("gateway chat transcript writes (guardrail)", () => {
|
||||
});
|
||||
|
||||
describe("exec approval handlers", () => {
|
||||
const execApprovalNoop = () => false;
|
||||
const execApprovalNoop = () => {};
|
||||
type ExecApprovalHandlers = ReturnType<typeof createExecApprovalHandlers>;
|
||||
type ExecApprovalRequestArgs = Parameters<ExecApprovalHandlers["exec.approval.request"]>[0];
|
||||
type ExecApprovalResolveArgs = Parameters<ExecApprovalHandlers["exec.approval.resolve"]>[0];
|
||||
type ExecApprovalRequestRespond = ExecApprovalRequestArgs["respond"];
|
||||
type ExecApprovalResolveRespond = ExecApprovalResolveArgs["respond"];
|
||||
|
||||
const defaultExecApprovalRequestParams = {
|
||||
command: "echo ok",
|
||||
@@ -359,7 +266,7 @@ describe("exec approval handlers", () => {
|
||||
|
||||
async function requestExecApproval(params: {
|
||||
handlers: ExecApprovalHandlers;
|
||||
respond: ExecApprovalRequestRespond;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
context: { broadcast: (event: string, payload: unknown) => void };
|
||||
params?: Record<string, unknown>;
|
||||
}) {
|
||||
@@ -380,24 +287,14 @@ describe("exec approval handlers", () => {
|
||||
async function resolveExecApproval(params: {
|
||||
handlers: ExecApprovalHandlers;
|
||||
id: string;
|
||||
respond: ExecApprovalResolveRespond;
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
context: { broadcast: (event: string, payload: unknown) => void };
|
||||
}) {
|
||||
return params.handlers["exec.approval.resolve"]({
|
||||
params: { id: params.id, decision: "allow-once" } as ExecApprovalResolveArgs["params"],
|
||||
respond: params.respond,
|
||||
context: toExecApprovalResolveContext(params.context),
|
||||
client: {
|
||||
connect: {
|
||||
client: {
|
||||
id: "cli",
|
||||
displayName: "CLI",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "cli",
|
||||
},
|
||||
},
|
||||
} as unknown as ExecApprovalResolveArgs["client"],
|
||||
client: { connect: { client: { id: "cli", displayName: "CLI" } } },
|
||||
req: { id: "req-2", type: "req", method: "exec.approval.resolve" },
|
||||
isWebchatConnect: execApprovalNoop,
|
||||
});
|
||||
@@ -407,7 +304,7 @@ describe("exec approval handlers", () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const broadcasts: Array<{ event: string; payload: unknown }> = [];
|
||||
const respond = vi.fn() as unknown as ExecApprovalRequestRespond;
|
||||
const respond = vi.fn();
|
||||
const context = {
|
||||
broadcast: (event: string, payload: unknown) => {
|
||||
broadcasts.push({ event, payload });
|
||||
@@ -478,7 +375,7 @@ describe("exec approval handlers", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const resolveRespond = vi.fn() as unknown as ExecApprovalResolveRespond;
|
||||
const resolveRespond = vi.fn();
|
||||
await resolveExecApproval({
|
||||
handlers,
|
||||
id,
|
||||
@@ -501,7 +398,7 @@ describe("exec approval handlers", () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const respond = vi.fn();
|
||||
const resolveRespond = vi.fn() as unknown as ExecApprovalResolveRespond;
|
||||
const resolveRespond = vi.fn();
|
||||
|
||||
const resolveContext = {
|
||||
broadcast: () => {},
|
||||
@@ -582,7 +479,7 @@ describe("gateway healthHandlers.status scope handling", () => {
|
||||
await healthHandlers.status({
|
||||
respond,
|
||||
client: { connect: { role: "operator", scopes: ["operator.read"] } },
|
||||
} as unknown as HealthStatusHandlerParams);
|
||||
} as HealthStatusHandlerParams);
|
||||
|
||||
expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: false });
|
||||
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
@@ -596,62 +493,13 @@ describe("gateway healthHandlers.status scope handling", () => {
|
||||
await healthHandlers.status({
|
||||
respond,
|
||||
client: { connect: { role: "operator", scopes: ["operator.admin"] } },
|
||||
} as unknown as HealthStatusHandlerParams);
|
||||
} as HealthStatusHandlerParams);
|
||||
|
||||
expect(vi.mocked(status.getStatusSummary)).toHaveBeenCalledWith({ includeSensitive: true });
|
||||
expect(respond).toHaveBeenCalledWith(true, { ok: true }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gateway mesh.plan.auto scope handling", () => {
|
||||
it("rejects operator.read clients for mesh.plan.auto", async () => {
|
||||
const { handleGatewayRequest } = await import("../server-methods.js");
|
||||
const respond = vi.fn();
|
||||
const handler = vi.fn();
|
||||
|
||||
await handleGatewayRequest({
|
||||
req: { id: "req-mesh-read", type: "req", method: "mesh.plan.auto", params: {} },
|
||||
respond,
|
||||
context: {} as Parameters<typeof handleGatewayRequest>[0]["context"],
|
||||
client: { connect: { role: "operator", scopes: ["operator.read"] } } as unknown as Parameters<
|
||||
typeof handleGatewayRequest
|
||||
>[0]["client"],
|
||||
isWebchatConnect: () => false,
|
||||
extraHandlers: { "mesh.plan.auto": handler },
|
||||
});
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: "missing scope: operator.write" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows operator.write clients for mesh.plan.auto", async () => {
|
||||
const { handleGatewayRequest } = await import("../server-methods.js");
|
||||
const respond = vi.fn();
|
||||
const handler = vi.fn(
|
||||
({ respond: send }: { respond: (ok: boolean, payload?: unknown) => void }) =>
|
||||
send(true, { ok: true }),
|
||||
);
|
||||
|
||||
await handleGatewayRequest({
|
||||
req: { id: "req-mesh-write", type: "req", method: "mesh.plan.auto", params: {} },
|
||||
respond,
|
||||
context: {} as Parameters<typeof handleGatewayRequest>[0]["context"],
|
||||
client: {
|
||||
connect: { role: "operator", scopes: ["operator.write"] },
|
||||
} as unknown as Parameters<typeof handleGatewayRequest>[0]["client"],
|
||||
isWebchatConnect: () => false,
|
||||
extraHandlers: { "mesh.plan.auto": handler },
|
||||
});
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
expect(respond).toHaveBeenCalledWith(true, { ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("logs.tail", () => {
|
||||
const logsNoop = () => false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user