mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
feat: review and resolve preserved agent instruction edits
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
5774c98d50
commit
0fbe00eeec
15 files changed
+248
-9
No files matched your search
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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) =>
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user