fix(runner): return the edited agent's link and effect in instruction save receipts

Completion feedback forbids guessing routes when no href was returned, and
the instruction tools returned none, so chat replies after an instruction
edit could not link the edited agent. Save and restore receipts now carry a
resourceLinks entry with the agent's real profile href and state that runs
starting after the save use the new instructions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 committed 2026-11-07 20:49:56 -06:00
1 parent 4b367425e6
commit 988094c256
2 files changed
+23 -3

No files matched your search

@@ -59,6 +59,12 @@ describe("canonical instruction tools through native authority", () => {
expect(first).toMatchObject({ entryFile, content: original });
const changed = await call("update_agent_instructions", { entryFile, content: "changed\r\n", baseRevisionId: first.revision.id });
expect(changed.revision).toMatchObject({ responsibleUserId: userId, actorAgentId: agentId, sourceRunId: runId });
// The chat reply must not guess routes, so the receipt carries the edited
// agent's real profile href and when the saved instructions take effect.
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId));
expect(changed.resourceLinks).toEqual([{ kind: "agent", id: targetAgentId, name: "Target",
href: `/${encodeURIComponent(company!.issuePrefix)}/agents/${targetAgentId}` }]);
expect(changed.appliesTo).toMatch(/start after this save/);
expect((await call("get_agent_instruction_history", { entryFile })).revisions).toHaveLength(0);
expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("changed\r\n");
await expect(call("update_agent_instructions", { entryFile, content: "stale", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 409 });
@@ -200,7 +206,7 @@ describe("canonical instruction tools through native authority", () => {
await expect(authority.execute(request)).rejects.toThrow("injected lost commit acknowledgement");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
const receipt = (run!.resultJson as any).semanticToolReceipts[`instruction:${request.callId}`].result;
expect(receipt).toMatchObject({ changed: true, content });
expect(receipt).toMatchObject({ changed: true, content, resourceLinks: [{ kind: "agent", id: targetAgentId, name: "Target" }] });
authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
expect(await authority.recover(request)).toEqual({ result: receipt });
expect(await authority.recover({ ...request, callId: randomUUID() })).toBeNull();
@@ -1,6 +1,7 @@
import type { AgentInstructionCommitReceipt } from "@paperclipai/shared";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { agents, companies, 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";
@@ -44,7 +45,7 @@ export async function executeAgentInstructionTool(input: {
...scope(targetAgentId), runId: input.binding.runId, entryFile: receipt.revision.entryFile,
revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash,
}).catch(() => undefined);
return receipt;
return { ...receipt, ...(await savedAgentResource(input.db, scope(targetAgentId))) };
};
switch (input.tool) {
case "read_agent_instructions": {
@@ -74,3 +75,16 @@ export async function executeAgentInstructionTool(input: {
}
}
}
/** Runs snapshot instructions when their runtime context is built, so a save
* reaches only runs that start afterward. The chat reply needs a real profile
* href for the edited agent; completion guidance forbids guessing routes. */
const INSTRUCTION_SAVE_EFFECT = "Runs that start after this save use these instructions. Runs already in progress keep the instructions they started with.";
async function savedAgentResource(db: Db, target: { companyId: string; agentId: string }) {
const [row] = await db.select({ name: agents.name, issuePrefix: companies.issuePrefix }).from(agents)
.innerJoin(companies, eq(companies.id, agents.companyId))
.where(and(eq(agents.id, target.agentId), eq(agents.companyId, target.companyId)));
if (!row) return { appliesTo: INSTRUCTION_SAVE_EFFECT };
const href = `/${encodeURIComponent(row.issuePrefix)}/agents/${encodeURIComponent(target.agentId)}`;
return { appliesTo: INSTRUCTION_SAVE_EFFECT, resourceLinks: [{ kind: "agent", id: target.agentId, name: row.name, href }] };
}