feat: review and resolve preserved agent instruction edits

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 16:34:10 -05:00
1 parent 5774c98d50
commit 0fbe00eeec
15 files changed
+248 -9

No files matched your search

@@ -24,6 +24,9 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
}));
const mockInstructionWorkingCopies = vi.hoisted(() => ({ list: vi.fn(), resolve: vi.fn(), acknowledgeExplicitSave: vi.fn() }));
vi.mock("../services/agent-instruction-working-copies.js", () => ({ agentInstructionWorkingCopyService: () => mockInstructionWorkingCopies }));
const mockInstructionRevisions = vi.hoisted(() => ({ readCurrent: vi.fn(), commit: vi.fn(), restore: vi.fn(), history: vi.fn(), readRevision: vi.fn(), diff: vi.fn(), materializeCurrent: vi.fn() }));
vi.mock("../services/agent-instruction-revisions.js", () => ({ agentInstructionRevisionService: () => mockInstructionRevisions }));
@@ -615,6 +618,27 @@ describe("agent instructions bundle routes", () => {
expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
});
it("lists and resolves preserved edits with server scope, actor, and explicit revision", async () => {
const app = await createApp();
const runId = "55555555-5555-4555-8555-555555555555";
const baseRevisionId = "44444444-4444-4444-8444-444444444444";
const prefix = "/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/candidates";
mockInstructionWorkingCopies.list.mockResolvedValue([{ runId, entryFile: "AGENTS.md", state: "conflict", content: "preserved" }]);
mockInstructionWorkingCopies.resolve.mockResolvedValue({ revision: { id: baseRevisionId, entryFile: "AGENTS.md", byteLength: 8 }, content: "resolved", changed: true, materialization: "current" });
const listed = await requestApp(app, (url) => request(url).get(prefix));
expect(listed.status).toBe(200);
expect(listed.body).toEqual([{ runId, entryFile: "AGENTS.md", state: "conflict", content: "preserved" }]);
expect(mockInstructionWorkingCopies.list).toHaveBeenCalledWith("company-1", "11111111-1111-4111-8111-111111111111", expect.objectContaining({ type: "board", userId: "local-board" }));
const resolved = await requestApp(app, (url) => request(url).post(`${prefix}/${runId}/resolve`).send({ baseRevisionId, content: "resolved" }));
expect(resolved.status).toBe(200);
expect(resolved.body).toMatchObject({ content: "resolved", receipt: { changed: true } });
expect(mockInstructionWorkingCopies.resolve).toHaveBeenCalledWith({ companyId: "company-1", agentId: "11111111-1111-4111-8111-111111111111", runId, baseRevisionId, content: "resolved" }, expect.objectContaining({ type: "board", userId: "local-board" }));
for (const body of [{ content: "missing base" }, { baseRevisionId, content: "forged", responsibleUserId: "other" }, { baseRevisionId, content: "forged", entryFile: "other.md" }]) {
expect((await requestApp(app, (url) => request(url).post(`${prefix}/${runId}/resolve`).send(body))).status).toBe(400);
}
expect(mockInstructionWorkingCopies.resolve).toHaveBeenCalledOnce();
});
it("exposes scoped history, diff and restore with the server actor", async () => {
const app = await createApp();
const revisionId = "33333333-3333-4333-8333-333333333333";
+31
View File
@@ -39,6 +39,7 @@ import {
type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
restoreAgentInstructionSchema,
resolveAgentInstructionCandidateSchema,
updateAgentInstructionsBundleSchema,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
@@ -65,6 +66,7 @@ import { trackAgentCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js";
import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
import { instructionPath } from "../services/agent-instruction-files.js";
import { agentInstructionsBundleMode, deriveBundleState } from "../services/agent-instructions.js";
import {
@@ -729,6 +731,7 @@ export function agentRoutes(
const secretsSvc = secretService(db);
const instructions = agentInstructionsService(db);
const instructionRevisions = agentInstructionRevisionService(db);
const instructionWorkingCopies = agentInstructionWorkingCopyService(db);
function instructionFileDetail(snapshot: import("@paperclipai/shared").AgentInstructionSnapshot,
receipt?: import("@paperclipai/shared").AgentInstructionCommitReceipt) {
const path = snapshot.revision.entryFile;
@@ -5178,6 +5181,11 @@ export function agentRoutes(
const receipt = await instructionRevisions.commit({ companyId: existing.companyId, agentId: existing.id,
entryFile, content: req.body.content, baseRevisionId: req.body.baseRevisionId,
source: req.actor.type === "board" ? "board" : "api" }, req.actor);
if (req.actor.type === "agent" && req.actor.runId) {
await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id,
runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id,
contentHash: receipt.revision.contentHash }).catch(() => undefined);
}
if (req.body.clearLegacyPromptTemplate) {
const fresh = await svc.getById(existing.id);
if (fresh) {
@@ -5234,6 +5242,24 @@ export function agentRoutes(
res.json(result.file);
});
router.get("/agents/:id/instructions-bundle/candidates", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
res.json(await instructionWorkingCopies.list(existing.companyId, existing.id, req.actor));
});
router.post("/agents/:id/instructions-bundle/candidates/:runId/resolve", validate(resolveAgentInstructionCandidateSchema), async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
assertExternalInstructionsAdmin(req, existing);
const runId = req.params.runId as string;
if (!isUuidLike(runId)) throw unprocessable("Invalid instruction candidate run id");
const receipt = await instructionWorkingCopies.resolve({ companyId: existing.companyId, agentId: existing.id,
runId, baseRevisionId: req.body.baseRevisionId, content: req.body.content }, req.actor);
res.json(instructionFileDetail(receipt, receipt));
});
router.get("/agents/:id/instructions-bundle/history", async (req, res) => {
const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
if (!existing) return;
@@ -5268,6 +5294,11 @@ export function agentRoutes(
assertExternalInstructionsAdmin(req, existing);
const receipt = await instructionRevisions.restore({ companyId: existing.companyId, agentId: existing.id,
entryFile: req.body.path, baseRevisionId: req.body.baseRevisionId, revisionId: req.body.revisionId }, req.actor);
if (req.actor.type === "agent" && req.actor.runId) {
await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id,
runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id,
contentHash: receipt.revision.contentHash }).catch(() => undefined);
}
res.json(instructionFileDetail(receipt, receipt));
});
@@ -1,7 +1,9 @@
import type { AgentInstructionCommitReceipt } from "@paperclipai/shared";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { badRequest, notFound } from "../../errors.js";
import { agentInstructionRevisionService } from "../agent-instruction-revisions.js";
import { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js";
import { deriveBundleState } from "../agent-instructions.js";
import { agentService } from "../agents.js";
import type { AuthorizationActor } from "../authorization.js";
@@ -35,6 +37,15 @@ export async function executeAgentInstructionTool(input: {
return result.data;
};
const scope = (targetAgentId?: string) => ({ companyId: input.binding.companyId, agentId: targetAgentId ?? input.binding.agentId });
const acknowledge = async (receipt: AgentInstructionCommitReceipt, targetAgentId?: string) => {
// The commit is already durable. A failed baseline refresh keeps the old CAS
// fence, so later cleanup preserves a conflict instead of overwriting it.
await agentInstructionWorkingCopyService(input.db).acknowledgeExplicitSave({
...scope(targetAgentId), runId: input.binding.runId, entryFile: receipt.revision.entryFile,
revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash,
}).catch(() => undefined);
return receipt;
};
switch (input.tool) {
case "read_agent_instructions": {
const args = parse(schemas.read_agent_instructions);
@@ -49,7 +60,8 @@ export async function executeAgentInstructionTool(input: {
}
case "update_agent_instructions": {
const args = parse(schemas.update_agent_instructions);
return service.commit({ ...scope(args.targetAgentId), entryFile: args.entryFile, content: args.content, baseRevisionId: args.baseRevisionId, source: "tool" }, actor);
const receipt = await service.commit({ ...scope(args.targetAgentId), entryFile: args.entryFile, content: args.content, baseRevisionId: args.baseRevisionId, source: "tool" }, actor);
return acknowledge(receipt, args.targetAgentId);
}
case "get_agent_instruction_history": {
const args = parse(schemas.get_agent_instruction_history);
@@ -57,7 +69,8 @@ export async function executeAgentInstructionTool(input: {
}
case "restore_agent_instructions": {
const args = parse(schemas.restore_agent_instructions);
return service.restore({ ...scope(args.targetAgentId), entryFile: args.entryFile, revisionId: args.revisionId, baseRevisionId: args.baseRevisionId }, actor);
const receipt = await service.restore({ ...scope(args.targetAgentId), entryFile: args.entryFile, revisionId: args.revisionId, baseRevisionId: args.baseRevisionId }, actor);
return acknowledge(receipt, args.targetAgentId);
}
}
}