Menu
popagent
publicLatest change cdf1d4b8bab18c97cbff7aae6e7c259b98d7123d - Do not fail self-update research on a recovered containment denial by AkurAI Build
import { describe, expect, test } from "bun:test";
import type { Agent } from "@mastra/core/agent";
import type { AgentTask } from "./api-types";
import type { AgentExecutionRuntime } from "./agent-execution";
import {
isContainmentDeniedEvidence,
runSelfUpdateWorkflow,
type SelfUpdateWorkflowAgents,
type SelfUpdateWorkflowDependencies,
} from "./self-update-workflow";
import type { SelfUpdateRun } from "./self-update-policy";
const DIFF_DIGEST = "1".repeat(64);
const run: SelfUpdateRun = {
taskId: "workflow-task",
workspaceId: "workflow-workspace",
repositoryPath: "/registered",
path: "/contained/self-update",
branch: "autonomous/self-update/test",
snapshot: { head: "a".repeat(40), branch: "main", remotes: "remote-hash" },
cloneRemotes: "clone-remote-hash",
delegations: new Set<string>(),
deploymentEnabled: false,
};
function task(): AgentTask {
return {
id: run.taskId,
sessionId: "workflow-session",
scheduleId: "workflow-schedule",
source: "self-update",
workspaceId: run.workspaceId,
prompt: "Improve the contained repository",
model: "test/model",
status: "running",
output: null,
error: null,
stepsCompleted: 0,
progress: null,
recoveryCount: 0,
attemptCount: 1,
maxAttempts: 1,
nextAttemptAt: null,
lastErrorClass: null,
deadLetteredAt: null,
createdAt: "",
startedAt: "",
completedAt: null,
};
}
function fakeAgent(
onGenerate: (phase: string) => unknown | Promise<unknown>,
): Agent {
const fake = {
__fork: () => fake,
setBrowser: () => undefined,
getModel: () => ({ provider: "test", modelId: "structurer" }),
stream: async (_prompt: string, options: { runId?: string }) => ({
object: Promise.resolve(await onGenerate(options.runId?.split("-").at(-1) ?? "")),
}),
};
return fake as unknown as Agent;
}
function agents(
onGenerate: (phase: string) => unknown | Promise<unknown>,
): SelfUpdateWorkflowAgents {
return {
researcher: fakeAgent(onGenerate),
orchistrator: fakeAgent(onGenerate),
implementer: fakeAgent(onGenerate),
reviewer: fakeAgent(onGenerate),
};
}
function dependencies(
signal: AbortSignal,
workflowAgents: SelfUpdateWorkflowAgents,
calls: string[],
reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }>,
): SelfUpdateWorkflowDependencies {
let inspections = 0;
return {
execution: {
prepare: async (input: { prompt: string }) => ({
prompt: input.prompt,
options: {},
}),
} as unknown as AgentExecutionRuntime,
policy: {
begin: async () => run,
observeDelegation: () => undefined,
inspect: async () => {
inspections += 1;
return inspections === 1
? {
changedPaths: [],
diffCheck: "clean",
diffEvidence: "",
diffDigest: DIFF_DIGEST,
clean: true,
}
: {
changedPaths: ["src/change.ts"],
diffCheck: "checked",
diffEvidence: "diff --git a/src/change.ts b/src/change.ts",
diffDigest: DIFF_DIGEST,
clean: false,
};
},
verify: async () => {
calls.push("verify");
return "verified";
},
commit: async () => {
calls.push("commit");
return "commit-id";
},
accept: async () => {
calls.push("accept");
},
reject: async () => {
calls.push("reject");
},
},
agents: workflowAgents,
report: async (detail) => {
reports.push({
state: detail.state,
phases: detail.phases.map((phase) => ({ id: phase.id, state: phase.state })),
});
},
signal,
};
}
describe("self-update workflow contracts", () => {
test("recognizes varied containment-denial evidence", () => {
expect(isContainmentDeniedEvidence("Self-update containment denied a protected path")).toBe(true);
expect(isContainmentDeniedEvidence("The operation was denied at the containment level")).toBe(true);
expect(isContainmentDeniedEvidence("Repository inspection completed inside containment")).toBe(false);
});
test("no-op completes without implementation, review, or commit", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const result = await runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(
new AbortController().signal,
agents((phase) => {
if (phase === "research") {
calls.push("research");
return { summary: "Nothing requires a source change", evidence: [], relevantPaths: [], noOp: true };
}
calls.push("decide");
return { rationale: "The requested state is already satisfied", noOp: true, verificationCommand: null };
}),
calls,
reports,
),
);
expect(result).toBe("Self-update verified no-op with no source changes");
expect(calls).toEqual(["research", "decide", "accept"]);
expect(reports.at(-1)?.state).toBe("completed");
expect(reports.at(-1)?.phases.filter((phase) => phase.state === "skipped").map((phase) => phase.id)).toEqual([
"implement",
"inspect-change",
"verify",
"review",
"commit",
]);
});
test("a recovered containment denial does not fail research that still inspected the checkout", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const result = await runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(
new AbortController().signal,
agents((phase) => {
if (phase === "research") {
calls.push("research");
return {
summary: "One listing attempt was refused, then inspection completed",
evidence: ["Self-update containment denied hidden-file listing; retried without showHidden and read the file"],
relevantPaths: ["src/tasks.ts"],
noOp: true,
};
}
calls.push("decide");
return { rationale: "Already satisfied", noOp: true, verificationCommand: null };
}),
calls,
reports,
),
);
expect(result).toBe("Self-update verified no-op with no source changes");
expect(reports.at(-1)?.state).toBe("completed");
});
test("research that inspected nothing because containment denied it still fails", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
await expect(runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(
new AbortController().signal,
agents(() => ({
summary: "Self-update containment denied every read attempt",
evidence: ["Self-update containment denied a protected path"],
relevantPaths: [],
noOp: true,
})),
calls,
reports,
),
)).rejects.toThrow(/blocked by self-update containment/);
});
test("reports safe structured-handoff trace stages", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const trace: Array<{ phase: string; stage: string; attempt: number }> = [];
const configured = dependencies(
new AbortController().signal,
agents((phase) => phase === "research"
? { summary: "No change", evidence: [], relevantPaths: [], noOp: true }
: { rationale: "Already satisfied", noOp: true, verificationCommand: null }),
calls,
reports,
);
configured.traceHandoff = (event) => trace.push(event);
await runSelfUpdateWorkflow(task(), "workflow-turn", "workflow-trace", configured);
expect(trace).toEqual([
{ phase: "research", stage: "stream-start", attempt: 1 },
{ phase: "research", stage: "stream-created", attempt: 1 },
{ phase: "research", stage: "object-resolved", attempt: 1 },
{ phase: "decide", stage: "stream-start", attempt: 1 },
{ phase: "decide", stage: "stream-created", attempt: 1 },
{ phase: "decide", stage: "object-resolved", attempt: 1 },
]);
});
test("uses the orchestrator model to structure local specialist output", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const modes: unknown[] = [];
const structurer = { provider: "test", modelId: "structurer" };
const agent = {
__fork: () => agent,
setBrowser: () => undefined,
getModel: () => structurer,
stream: async (_prompt: string, options: {
runId?: string;
structuredOutput?: { jsonPromptInjection?: unknown; model?: unknown };
}) => {
modes.push([options.structuredOutput?.jsonPromptInjection, options.structuredOutput?.model]);
const phase = options.runId?.split("-").at(-1);
return {
object: Promise.resolve(phase === "research"
? { summary: "No change", evidence: [], relevantPaths: [], noOp: true }
: { rationale: "Already satisfied", noOp: true, verificationCommand: null }),
};
},
} as unknown as Agent;
await runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
);
expect(modes).toEqual([["auto", structurer], ["auto", structurer]]);
});
test("gives repository research the configured specialist budget", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const maxSteps: Array<[string | undefined, number | undefined]> = [];
const agent = {
__fork: () => agent,
setBrowser: () => undefined,
getModel: () => ({ provider: "test", modelId: "structurer" }),
stream: async (_prompt: string, options: { runId?: string; maxSteps?: number }) => {
const phase = options.runId?.split("-").at(-1);
maxSteps.push([phase, options.maxSteps]);
return {
object: Promise.resolve(phase === "research"
? { summary: "No change", evidence: [], relevantPaths: [], noOp: true }
: { rationale: "Already satisfied", noOp: true, verificationCommand: null }),
};
},
} as unknown as Agent;
await runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
);
expect(maxSteps[0]).toEqual(["research", 12]);
});
test("recovers structured handoffs from the streamed JSON text", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const agent = {
__fork: () => agent,
setBrowser: () => undefined,
getModel: () => ({ provider: "test", modelId: "structurer" }),
stream: async (_prompt: string, options: { runId?: string }) => {
const phase = options.runId?.split("-").at(-1);
return {
object: Promise.resolve(phase === "research"
? { result: { summary: "No change", evidence: { path: "src/agent.ts", finding: "No implementation needed" }, relevantPaths: [], noOp: true } }
: undefined),
text: Promise.resolve(`handoff follows\n${JSON.stringify({ rationale: "Already satisfied", noOp: true, verificationCommand: null })}\nfinished`),
};
},
} as unknown as Agent;
const result = await runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
);
expect(result).toBe("Self-update verified no-op with no source changes");
expect(reports.at(-1)?.state).toBe("completed");
});
test("synthesizes a tool-free handoff when the specialist exhausts its steps with no text", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const streams: Array<{ runId?: string; toolChoice?: string; maxSteps?: number; prompt: unknown }> = [];
const agent = {
__fork: () => agent,
setBrowser: () => undefined,
getModel: () => ({ provider: "test", modelId: "structurer" }),
stream: async (prompt: unknown, options: { runId?: string; toolChoice?: string; maxSteps?: number }) => {
streams.push({ ...options, prompt });
const phase = options.runId?.split("-").at(-1);
if (phase === "research") {
// Budget exhausted mid-tool-call: no object, no text, dangling assistant tail.
return {
object: Promise.resolve(undefined),
text: Promise.resolve(""),
// response.messages carries only generated turns, never the request.
response: Promise.resolve({ messages: [
{ role: "assistant", content: [{ type: "tool-call", toolName: "grep" }] },
{ role: "tool", content: [{ type: "tool-result", toolName: "grep" }] },
{ role: "assistant", content: [{ type: "tool-call", toolName: "read" }] },
] }),
};
}
if (phase === "synthesis") {
return { object: Promise.resolve({ summary: "No change", evidence: [], relevantPaths: [], noOp: true }) };
}
return { object: Promise.resolve({ rationale: "Already satisfied", noOp: true, verificationCommand: null }) };
},
} as unknown as Agent;
const result = await runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
);
expect(result).toBe("Self-update verified no-op with no source changes");
const synthesis = streams.find((entry) => entry.runId?.endsWith("-research-synthesis"));
expect(synthesis).toMatchObject({ toolChoice: "none", maxSteps: 1 });
const history = synthesis?.prompt as Array<{ role: string; content: unknown }>;
expect(history.map((message) => message.role)).toEqual(["user", "assistant", "tool", "user"]);
// The original research request leads the synthesis conversation.
expect(String(history[0]?.content)).toContain("Research the requested contained self-update");
});
test("bounds oversized research evidence instead of failing the workflow", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string; evidence: string[] }> }> = [];
const oversized = "x".repeat(501);
const result = await runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, agents((phase) => phase === "research"
? { summary: "No change", evidence: ["bounded", oversized], relevantPaths: [], noOp: true }
: { rationale: "Already satisfied", noOp: true, verificationCommand: null }), calls, reports),
);
expect(result).toBe("Self-update verified no-op with no source changes");
});
test("retries one malformed structured handoff", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
let attempts = 0;
const agent = {
__fork: () => agent,
setBrowser: () => undefined,
getModel: () => ({ provider: "test", modelId: "structurer" }),
stream: async (_prompt: string, options: { runId?: string }) => {
const phase = options.runId?.split("-").at(-1);
return {
object: phase === "research" && attempts++ === 0
? Promise.reject(new SyntaxError("JSON Parse error: Unrecognized token '#'"))
: Promise.resolve(phase === "research"
? { summary: "No change", evidence: [], relevantPaths: [], noOp: true }
: { rationale: "Already satisfied", noOp: true, verificationCommand: null }),
};
},
} as unknown as Agent;
await expect(runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
)).resolves.toBe("Self-update verified no-op with no source changes");
expect(attempts).toBe(2);
});
test("retries two malformed structured handoffs", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
let attempts = 0;
const agent = {
__fork: () => agent,
setBrowser: () => undefined,
getModel: () => ({ provider: "test", modelId: "structurer" }),
stream: async (_prompt: string, options: { runId?: string }) => {
const phase = options.runId?.split("-").at(-1);
return {
object: phase === "research" && attempts++ < 2
? Promise.reject(new SyntaxError("JSON Parse error: Unexpected EOF"))
: Promise.resolve(phase === "research"
? { summary: "No change", evidence: [], relevantPaths: [], noOp: true }
: { rationale: "Already satisfied", noOp: true, verificationCommand: null }),
};
},
} as unknown as Agent;
await expect(runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
)).resolves.toBe("Self-update verified no-op with no source changes");
expect(attempts).toBe(3);
});
test("retries one transient phase failure", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
let attempts = 0;
const agent = fakeAgent((phase) => {
if (phase === "research" && attempts++ === 0) throw new Error("socket connection was closed unexpectedly");
return phase === "research"
? { summary: "No change", evidence: [], relevantPaths: [], noOp: true }
: { rationale: "Already satisfied", noOp: true, verificationCommand: null };
});
await expect(runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(new AbortController().signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
)).resolves.toBe("Self-update verified no-op with no source changes");
expect(attempts).toBe(2);
});
test("does not retry a transient failure after the run has been cancelled", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const controller = new AbortController();
let attempts = 0;
const agent = fakeAgent((phase) => {
if (phase === "research") {
attempts += 1;
controller.abort();
throw new Error("SyntaxError: JSON Parse error: Unexpected EOF");
}
throw new Error(`unexpected phase ${phase}`);
});
await expect(runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(controller.signal, {
researcher: agent,
orchistrator: agent,
implementer: agent,
reviewer: agent,
}, calls, reports),
)).rejects.toThrow();
expect(attempts).toBe(1);
expect(reports.at(-1)?.state).toBe("cancelled");
});
test("research failure prevents commit", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
await expect(runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(
new AbortController().signal,
agents((phase) => {
calls.push(phase);
if (phase === "research") throw new Error("research failed");
throw new Error(`unexpected phase ${phase}`);
}),
calls,
reports,
),
)).rejects.toThrow("Research phase failed: research failed");
expect(calls).toEqual(["research", "reject"]);
expect(calls).not.toContain("commit");
expect(reports.at(-1)?.state).toBe("failed");
expect(reports.at(-1)?.phases.find((phase) => phase.id === "research")?.state).toBe("failed");
});
test("cancellation prevents later phases", async () => {
const calls: string[] = [];
const reports: Array<{ state: string; phases: Array<{ id: string; state: string }> }> = [];
const controller = new AbortController();
await expect(runSelfUpdateWorkflow(
task(),
"workflow-turn",
"workflow-trace",
dependencies(
controller.signal,
agents((phase) => {
calls.push(phase);
if (phase === "research") {
controller.abort();
return { summary: "Research complete", evidence: [], relevantPaths: [], noOp: false };
}
throw new Error(`unexpected phase ${phase}`);
}),
calls,
reports,
),
)).rejects.toThrow();
expect(calls).toEqual(["research", "reject"]);
expect(calls).not.toContain("commit");
expect(reports.at(-1)?.state).toBe("cancelled");
expect(reports.at(-1)?.phases.filter((phase) => phase.state === "skipped").map((phase) => phase.id)).toEqual([
"implement",
"inspect-change",
"verify",
"review",
"commit",
]);
});
});