From 3943048383af4ef3f942133243b58036c56ba56b Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 27 Sep 2026 16:34:10 -0500 Subject: [PATCH] feat: review and resolve preserved agent instruction edits Co-Authored-By: Paperclip --- doc/agent-instruction-revisions.md | 10 ++ packages/shared/src/index.ts | 3 + packages/shared/src/types/agent.ts | 15 +++ packages/shared/src/types/index.ts | 2 + packages/shared/src/validators/agent.ts | 6 ++ .../agent-instructions-routes.test.ts | 24 +++++ server/src/routes/agents.ts | 31 ++++++ .../native-runtime/agent-instruction-tools.ts | 17 +++- ui/src/api/agents.ts | 6 ++ ui/src/lib/queryKeys.ts | 2 + .../pages/AgentDetail.instructions.test.tsx | 36 +++++++ ui/src/pages/AgentDetail.tsx | 99 ++++++++++++++++++- 12 files changed, 245 insertions(+), 6 deletions(-) diff --git a/doc/agent-instruction-revisions.md b/doc/agent-instruction-revisions.md index cf925689982..832a587b51b 100644 --- a/doc/agent-instruction-revisions.md +++ b/doc/agent-instruction-revisions.md @@ -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. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 15f458a5e4c..23b920d783e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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"; diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index cda07d1c7b2..6752e3f82df 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -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; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b9dd9c280c0..87ace9cd47b 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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"; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 3086c4f30ae..358e6a945ba 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -296,3 +296,9 @@ export const updateAgentPermissionsSchema = z.object({ }); export type UpdateAgentPermissions = z.infer; + +export const resolveAgentInstructionCandidateSchema = z.object({ + baseRevisionId: z.string().uuid().nullable(), + content: z.string().max(1024 * 1024), +}).strict(); +export type ResolveAgentInstructionCandidate = z.infer; diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 5fdfc494d95..e783d68625b 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -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"; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 1d29fa758ad..4fca0a16543 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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)); }); diff --git a/server/src/services/native-runtime/agent-instruction-tools.ts b/server/src/services/native-runtime/agent-instruction-tools.ts index 46cee4fcb55..032e66699fa 100644 --- a/server/src/services/native-runtime/agent-instruction-tools.ts +++ b/server/src/services/native-runtime/agent-instruction-tools.ts @@ -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); } } } diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index b2050c0a42d..82eea1f8df5 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -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(agentPath(id, companyId, "/instructions-bundle/file"), data), + instructionCandidates: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/instructions-bundle/candidates")), + resolveInstructionCandidate: (id: string, runId: string, data: ResolveAgentInstructionCandidate, companyId?: string) => + api.post(agentPath(id, companyId, `/instructions-bundle/candidates/${runId}/resolve`), data), instructionHistory: (id: string, path: string, companyId?: string, cursor?: string) => api.get(agentPath(id, companyId, `/instructions-bundle/history?path=${encodeURIComponent(path)}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`)), instructionRevision: (id: string, path: string, revisionId: string, companyId?: string) => diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 45d03d80db5..62da452e8d5 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -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, diff --git a/ui/src/pages/AgentDetail.instructions.test.tsx b/ui/src/pages/AgentDetail.instructions.test.tsx index 181da546841..bef634089e1 100644 --- a/ui/src/pages/AgentDetail.instructions.test.tsx +++ b/ui/src/pages/AgentDetail.instructions.test.tsx @@ -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; + 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('[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; diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index b18b77bde49..3f2f739bc44 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -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(null); const draftBaseRevisionRef = useRef(undefined); + const [candidateRunId, setCandidateRunId] = useState(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({ + {currentMode === "managed" && (candidates.data?.length ?? 0) > 0 && ( +
+

Preserved instruction edits

+

Review edits from a stopped run before saving them against the current instructions.

+ {candidates.data?.map((candidate) => ( +
+ {candidate.runId.slice(0, 8)} + {candidate.entryFile} ยท {formatDate(candidate.createdAt)} + + {candidate.errorMessage &&

{candidate.errorMessage}

} + {candidate.entryFile !== currentEntryFile &&

The instruction entry changed. These edits remain preserved for the original file.

} +
+ ))} +
+ )} + {candidateRunId &&
+

Reviewing preserved edits. Save to apply your resolved draft and close this preserved edit.

+
Compare current instructions +
{currentContent}
+
+ {draftBaseRevisionRef.current !== (selectedFileDetail?.revision?.id ?? null) &&

The current instructions changed after this draft was loaded. Refresh the current revision, compare the instructions, and save your resolved draft again.

} + {(resolveCandidate.error || draftBaseRevisionRef.current !== (selectedFileDetail?.revision?.id ?? null)) && } +
} + {(candidates.error || loadCandidate.error || resolveCandidate.error || refreshCandidateBase.error) &&

{(candidates.error ?? loadCandidate.error ?? resolveCandidate.error ?? refreshCandidateBase.error)?.message} Your preserved edits remain available.

} {(saveFile.error || fileError || updateBundle.error) &&

{(saveFile.error ?? fileError ?? updateBundle.error)?.message} Your unsaved edits are retained.

} {selectedFileDetail?.receipt?.materialization === "pending" &&

Revision saved. The instruction file still needs to be rebuilt from the saved revision.

} {selectedFileDetail?.revision && currentMode === "managed" &&