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

+10
View File
@@ -108,6 +108,8 @@ Existing company-scoped agent URLs now support:
| Revision content | `GET /api/agents/:id/instructions-bundle/revision/:revisionId?path=...` |
| Diff | `GET /api/agents/:id/instructions-bundle/diff?path=...&from=...&to=...` |
| Restore | `POST /api/agents/:id/instructions-bundle/restore` |
| Preserved edits | `GET /api/agents/:id/instructions-bundle/candidates` |
| Resolve preserved edits | `POST /api/agents/:id/instructions-bundle/candidates/:runId/resolve` |
Entry PUT requires `{ path, content, baseRevisionId }`. Restore requires
`{ path, revisionId, baseRevisionId }`. Content requests reject unknown fields,
@@ -187,3 +189,11 @@ A tool commit is immediately durable. If a run-local working copy still contains
older content, cleanup must preserve its conflict candidate rather than replace
the newer tool commit. A 409 response requires explicit conflict resolution;
never silently retry an old candidate against the latest revision.
The instructions editor lists preserved run edits, including conflicts and
unavailable collection results. Review loads the preserved bytes into an editable
draft and pins the current canonical revision. Saving resolves that candidate
through the same permission and CAS checks. Conflicts retain both the draft and
its original base. Candidate API responses contain content and diagnostic metadata;
server and sandbox filesystem roots remain private. Resolve requests accept only
`content` and `baseRevisionId`; the registered candidate supplies its entry file.
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaimLine truncated
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:1fa8ecf5d3c28e62a32e818de95633ff3efe5d4af1a100310516ff9b5ab2358d",
"catalogSha256": "sha256:e1328a8bef562cead7f1e373bb08bbcc7fbaa6be8977207f71dbbce262ebbb45",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "5f3144ea856ad27778f47de73eb542703b1d9df2abad2c427838fbaf69c3d59f",
"sha256": "1345ab5bc661d26e6a1ecee2510240467a54bbc083186ee8c684f44f70ed75a1",
"expectation": "accept",
"compatibilityCase": "canonical"
},
+3
View File
@@ -2791,3 +2791,6 @@ export { WORKSPACE_RESTORE_FAILURE_CODES, hasWorkspaceRestoreFailure, safeWorksp
export type { AgentInstructionErrorCode, AgentInstructionErrorDetails, AgentInstructionSource, AgentInstructionRevision, AgentInstructionSnapshot, AgentInstructionCommitReceipt, AgentInstructionHistory, AgentInstructionDiff } from "./types/agent.js";
export { restoreAgentInstructionSchema } from "./validators/agent.js";
export type { AgentInstructionCandidate } from "./types/agent.js";
export { resolveAgentInstructionCandidateSchema, type ResolveAgentInstructionCandidate } from "./validators/agent.js";
+15
View File
@@ -396,3 +396,18 @@ export interface AgentInstructionErrorDetails {
entryFile?: string;
reason?: string;
}
/** Preserved instruction edits; filesystem locations and responsible identity stay server-side. */
export interface AgentInstructionCandidate {
runId: string;
entryFile: string;
baseRevisionId: string | null;
baseHash: string;
state: "conflict" | "pending_collection" | "pending_commit" | "unavailable";
candidateHash: string | null;
content: string | null;
errorCode: string | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
}
+2
View File
@@ -1071,3 +1071,5 @@ export * from "./chat-github.js";
export * from "./email.js";
export type { AgentInstructionErrorCode, AgentInstructionErrorDetails, AgentInstructionSource, AgentInstructionRevision, AgentInstructionSnapshot, AgentInstructionCommitReceipt, AgentInstructionHistory, AgentInstructionDiff } from "./agent.js";
export type { AgentInstructionCandidate } from "./agent.js";
+6
View File
@@ -296,3 +296,9 @@ export const updateAgentPermissionsSchema = z.object({
});
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;
export const resolveAgentInstructionCandidateSchema = z.object({
baseRevisionId: z.string().uuid().nullable(),
content: z.string().max(1024 * 1024),
}).strict();
export type ResolveAgentInstructionCandidate = z.infer<typeof resolveAgentInstructionCandidateSchema>;
@@ -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);
}
}
}
+6
View File
@@ -6,6 +6,8 @@ import type {
AgentDetail,
AgentInstructionsBundle,
AgentInstructionHistory,
AgentInstructionCandidate,
ResolveAgentInstructionCandidate,
AgentInstructionDiff,
AgentInstructionSnapshot,
AgentInstructionsFileDetail,
@@ -173,6 +175,10 @@ export const agentsApi = {
data: { path: string; content: string; baseRevisionId?: string | null; clearLegacyPromptTemplate?: boolean },
companyId?: string,
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
instructionCandidates: (id: string, companyId?: string) =>
api.get<AgentInstructionCandidate[]>(agentPath(id, companyId, "/instructions-bundle/candidates")),
resolveInstructionCandidate: (id: string, runId: string, data: ResolveAgentInstructionCandidate, companyId?: string) =>
api.post<AgentInstructionsFileDetail>(agentPath(id, companyId, `/instructions-bundle/candidates/${runId}/resolve`), data),
instructionHistory: (id: string, path: string, companyId?: string, cursor?: string) =>
api.get<AgentInstructionHistory>(agentPath(id, companyId, `/instructions-bundle/history?path=${encodeURIComponent(path)}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`)),
instructionRevision: (id: string, path: string, revisionId: string, companyId?: string) =>
+2
View File
@@ -215,6 +215,8 @@ export const queryKeys = {
skills: (id: string) => ["agents", "skills", id] as const,
instructionsBundle: (id: string) =>
["agents", "instructions-bundle", id] as const,
instructionCandidates: (id: string) =>
["agents", "instruction-candidates", id] as const,
instructionsFile: (id: string, relativePath: string) =>
["agents", "instructions-bundle", id, "file", relativePath] as const,
keys: (agentId: string) => ["agents", "keys", agentId] as const,
@@ -15,6 +15,8 @@ const mockAgentsApi = vi.hoisted(() => ({
saveInstructionsFile: vi.fn(),
deleteInstructionsFile: vi.fn(),
instructionHistory: vi.fn(),
instructionCandidates: vi.fn(),
resolveInstructionCandidate: vi.fn(),
instructionDiff: vi.fn(),
restoreInstructions: vi.fn(),
}));
@@ -229,6 +231,7 @@ describe("PromptsTab instruction editor", () => {
saveAction = null;
markdownEditorRenderMock.mockClear();
Object.values(mockAgentsApi).forEach((mock) => mock.mockReset());
mockAgentsApi.instructionCandidates.mockResolvedValue([]);
mockAgentsApi.updateInstructionsBundle.mockResolvedValue({});
mockAgentsApi.saveInstructionsFile.mockImplementation(async (_agentId, data) => ({
path: data.path,
@@ -344,6 +347,39 @@ describe("PromptsTab instruction editor", () => {
});
});
it("loads preserved edits against the displayed head and retains their pinned base after a conflict", async () => {
const summary = makeSummary("AGENTS.md", "AGENTS.md");
const revision = { id: "head-1", entryFile: "AGENTS.md" } as NonNullable<AgentInstructionsFileDetail["revision"]>;
mockAgentsApi.instructionCandidates.mockResolvedValue([{ runId: "preserved-run", entryFile: "AGENTS.md", baseRevisionId: "older-base", baseHash: "base-hash", state: "conflict", candidateHash: "candidate-hash", content: "preserved edit", errorCode: "INSTRUCTION_REVISION_CONFLICT", errorMessage: "Instructions changed", createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" }]);
await renderPromptsTab(makeBundle("AGENTS.md", [summary]), { "AGENTS.md": makeDetail(summary, "current content", { revision }) });
await waitFor(() => expect(buttonByText(container, "Review preserved edits")).toBeDefined());
await act(async () => { buttonByText(container, "Review preserved edits").click(); });
const editor = await waitFor(() => {
const element = container.querySelector<HTMLTextAreaElement>('[data-testid="markdown-editor"]');
expect(element?.value).toBe("preserved edit"); return element!;
});
mockAgentsApi.resolveInstructionCandidate.mockRejectedValue(new Error("Instructions changed since the base revision"));
await waitFor(() => expect(saveAction).toEqual(expect.any(Function)));
await act(async () => { saveAction?.(); });
await waitFor(() => expect(mockAgentsApi.resolveInstructionCandidate).toHaveBeenCalledWith("agent-1", "preserved-run", { content: "preserved edit", baseRevisionId: "head-1" }, "company-1"));
expect(mockAgentsApi.saveInstructionsFile).not.toHaveBeenCalled();
expect(editor.value).toBe("preserved edit");
await act(async () => { queryClient.setQueryData(["agents", "instructions-bundle", "agent-1", "file", "AGENTS.md"], makeDetail(summary, "newer content", { revision: { ...revision, id: "head-2" } })); });
await act(async () => { saveAction?.(); });
await waitFor(() => expect(mockAgentsApi.resolveInstructionCandidate).toHaveBeenCalledTimes(2));
expect(mockAgentsApi.resolveInstructionCandidate.mock.lastCall?.[2].baseRevisionId).toBe("head-1");
expect(editor.value).toBe("preserved edit");
mockAgentsApi.instructionsFile.mockResolvedValue(makeDetail(summary, "latest content", { revision: { ...revision, id: "head-3" } }));
await waitFor(() => expect(buttonByText(container, "Refresh current revision").disabled).toBe(false));
await act(async () => { buttonByText(container, "Refresh current revision").click(); });
await waitFor(() => expect(container.textContent).toContain("latest content"));
expect(editor.value).toBe("preserved edit");
expect(mockAgentsApi.resolveInstructionCandidate).toHaveBeenCalledTimes(2);
await act(async () => { saveAction?.(); });
await waitFor(() => expect(mockAgentsApi.resolveInstructionCandidate).toHaveBeenCalledTimes(3));
expect(mockAgentsApi.resolveInstructionCandidate.mock.lastCall?.[2].baseRevisionId).toBe("head-3");
});
it("retains the draft and its base when a concurrent save conflicts", async () => {
const summary = makeSummary("AGENTS.md", "AGENTS.md");
const revision = { id: "base-1", entryFile: "AGENTS.md" } as NonNullable<AgentInstructionsFileDetail["revision"]>;
+95 -4
View File
@@ -1,3 +1,4 @@
import type { AgentInstructionCandidate } from "@paperclipai/shared";
import { InstructionHistory } from "../components/InstructionHistory";
import { AgentCharacter } from "../components/AgentCharacter";
import { characterStateForAgent } from "@paperclipai/shared";
@@ -2199,6 +2200,9 @@ export function PromptsTab({
const [showFilePanel, setShowFilePanel] = useState(false);
const [draft, setDraft] = useState<string | null>(null);
const draftBaseRevisionRef = useRef<string | null | undefined>(undefined);
const [candidateRunId, setCandidateRunId] = useState<string | null>(null);
const candidateAgentRef = useRef(agent.id);
candidateAgentRef.current = agent.id;
const [bundleDraft, setBundleDraft] = useState<{
mode: "managed" | "external";
rootPath: string;
@@ -2228,6 +2232,7 @@ export function PromptsTab({
const setSelectedFile = useCallback((filePath: string) => {
editorInteractedRef.current = false;
draftBaseRevisionRef.current = undefined;
setCandidateRunId(null);
setSelectedFileState(filePath);
}, []);
@@ -2293,6 +2298,59 @@ export function PromptsTab({
enabled: Boolean(companyId && isLocal && selectedFileExists),
});
const candidates = useQuery({
queryKey: queryKeys.agents.instructionCandidates(agent.id),
queryFn: () => agentsApi.instructionCandidates(agent.id, companyId),
enabled: Boolean(companyId && isLocal && currentMode === "managed"),
});
const loadCandidate = useMutation({
mutationFn: async (candidate: AgentInstructionCandidate) => {
if (candidate.content === null) throw new Error("These instruction edits have not been retrieved yet.");
const file = await agentsApi.instructionsFile(agent.id, candidate.entryFile, companyId).catch((error) => {
if (error instanceof ApiError && error.status === 404 && candidate.baseRevisionId === null) return null;
throw error;
});
return { candidate, file, agentId: agent.id };
},
onSuccess: ({ candidate, file, agentId: requestedAgentId }) => {
if (candidateAgentRef.current !== requestedAgentId) return;
setSelectedFile(candidate.entryFile);
if (file) queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, candidate.entryFile), file);
draftBaseRevisionRef.current = file?.revision?.id ?? null;
setCandidateRunId(candidate.runId);
setDraft(candidate.content);
setInstructionMode("edit");
},
});
const resolveCandidate = useMutation({
mutationFn: async (data: { runId: string; content: string; baseRevisionId: string | null }) => ({
file: await agentsApi.resolveInstructionCandidate(agent.id, data.runId, { content: data.content, baseRevisionId: data.baseRevisionId }, companyId),
agentId: agent.id,
}),
onSuccess: ({ file, agentId: requestedAgentId }) => {
if (candidateAgentRef.current !== requestedAgentId) return;
setDraft(null);
setCandidateRunId(null);
draftBaseRevisionRef.current = undefined;
queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, file.path), file);
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionCandidates(agent.id) });
},
});
const refreshCandidateBase = useMutation({
mutationFn: async () => ({
file: await agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
agentId: agent.id, runId: candidateRunId,
}),
onSuccess: ({ file, agentId: requestedAgentId, runId }) => {
if (candidateAgentRef.current !== requestedAgentId || candidateRunId !== runId) return;
draftBaseRevisionRef.current = file.revision?.id ?? null;
queryClient.setQueryData(queryKeys.agents.instructionsFile(agent.id, file.path), file);
resolveCandidate.reset();
},
});
const updateBundle = useMutation({
mutationFn: (data: {
mode?: "managed" | "external";
@@ -2457,8 +2515,8 @@ export function PromptsTab({
),
);
const fileDirty = draft !== null && draft !== currentContent;
const isDirty = bundleDirty || fileDirty;
const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh;
const isDirty = bundleDirty || fileDirty || candidateRunId !== null;
const isSaving = updateBundle.isPending || saveFile.isPending || resolveCandidate.isPending || loadCandidate.isPending || refreshCandidateBase.isPending || deleteFile.isPending || awaitingRefresh;
useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
@@ -2482,7 +2540,10 @@ export function PromptsTab({
entryFile: bundleDraft.entryFile,
});
}
if (fileDirty) {
if (candidateRunId) {
await resolveCandidate.mutateAsync({ runId: candidateRunId, content: displayValue,
baseRevisionId: draftBaseRevisionRef.current ?? null });
} else if (fileDirty) {
await saveFile.mutateAsync({
path: selectedOrEntryFile,
content: displayValue,
@@ -2497,6 +2558,8 @@ export function PromptsTab({
bundle,
bundleDirty,
bundleDraft,
candidateRunId,
resolveCandidate,
displayValue,
fileDirty,
isDirty,
@@ -2512,6 +2575,8 @@ export function PromptsTab({
useEffect(() => {
onCancelActionChange(isDirty ? () => {
draftBaseRevisionRef.current = undefined;
setCandidateRunId(null);
resolveCandidate.reset();
setDraft(null);
if (bundle) {
setBundleDraft({
@@ -2521,7 +2586,7 @@ export function PromptsTab({
});
}
} : null);
}, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath]);
}, [bundle, isDirty, onCancelActionChange, persistedMode, persistedRootPath, resolveCandidate]);
const handleSeparatorDrag = useCallback((event: React.MouseEvent) => {
event.preventDefault();
@@ -2948,6 +3013,32 @@ export function PromptsTab({
</div>
</div>
{currentMode === "managed" && (candidates.data?.length ?? 0) > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium">Preserved instruction edits</p>
<p className="text-sm text-muted-foreground">Review edits from a stopped run before saving them against the current instructions.</p>
{candidates.data?.map((candidate) => (
<div key={candidate.runId} className="flex flex-wrap items-center gap-3">
<span className="font-mono text-xs text-muted-foreground">{candidate.runId.slice(0, 8)}</span>
<span className="text-sm text-muted-foreground">{candidate.entryFile} · {formatDate(candidate.createdAt)}</span>
<Button type="button" variant="outline" size="sm"
disabled={isDirty || isSaving || candidate.content === null || candidate.entryFile !== currentEntryFile}
onClick={() => loadCandidate.mutate(candidate)}>Review preserved edits</Button>
{candidate.errorMessage && <p className="text-sm text-muted-foreground">{candidate.errorMessage}</p>}
{candidate.entryFile !== currentEntryFile && <p className="text-sm text-muted-foreground">The instruction entry changed. These edits remain preserved for the original file.</p>}
</div>
))}
</div>
)}
{candidateRunId && <div role="status" className="space-y-3">
<p className="text-sm text-muted-foreground">Reviewing preserved edits. Save to apply your resolved draft and close this preserved edit.</p>
<details><summary className="cursor-pointer text-sm text-muted-foreground">Compare current instructions</summary>
<pre className="whitespace-pre-wrap break-words rounded-md border border-border p-3 font-mono text-sm">{currentContent}</pre>
</details>
{draftBaseRevisionRef.current !== (selectedFileDetail?.revision?.id ?? null) && <p className="text-sm text-destructive">The current instructions changed after this draft was loaded. Refresh the current revision, compare the instructions, and save your resolved draft again.</p>}
{(resolveCandidate.error || draftBaseRevisionRef.current !== (selectedFileDetail?.revision?.id ?? null)) && <Button type="button" variant="outline" size="sm" disabled={isSaving} onClick={() => refreshCandidateBase.mutate()}>Refresh current revision</Button>}
</div>}
{(candidates.error || loadCandidate.error || resolveCandidate.error || refreshCandidateBase.error) && <p role="alert" className="text-sm text-destructive">{(candidates.error ?? loadCandidate.error ?? resolveCandidate.error ?? refreshCandidateBase.error)?.message} Your preserved edits remain available.</p>}
{(saveFile.error || fileError || updateBundle.error) && <p role="alert" className="text-sm text-destructive">{(saveFile.error ?? fileError ?? updateBundle.error)?.message} Your unsaved edits are retained.</p>}
{selectedFileDetail?.receipt?.materialization === "pending" && <p role="status" className="text-sm text-muted-foreground">Revision saved. The instruction file still needs to be rebuilt from the saved revision.</p>}
{selectedFileDetail?.revision && currentMode === "managed" && <InstructionHistory