diff --git a/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts index 0b617ba3a17..3a870cfaff1 100644 --- a/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts +++ b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts @@ -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(); diff --git a/server/src/services/native-runtime/agent-instruction-tools.ts b/server/src/services/native-runtime/agent-instruction-tools.ts index 032e66699fa..7b6cbea1948 100644 --- a/server/src/services/native-runtime/agent-instruction-tools.ts +++ b/server/src/services/native-runtime/agent-instruction-tools.ts @@ -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 }] }; +}