diff --git a/doc/runner-api-tools.md b/doc/runner-api-tools.md index e35df345626..319e4cd5d0b 100644 --- a/doc/runner-api-tools.md +++ b/doc/runner-api-tools.md @@ -149,7 +149,10 @@ The result contains `data` as text (including JSON), plus `responseText` with until it is null. Windows end at UTF-8 boundaries. The limit defaults to 24 KiB and accepts 4–24,576 bytes. The server rejects binary content, invalid UTF-8, and offsets inside a code point or beyond the response. This option only works -with GET; never repeat a mutation to retrieve another part of its response. +with GET; never repeat a mutation to retrieve another part of its response. A +new write call that carries `responseText` is refused before it is sent, like +`saveResponse`: it never carries request fields, so resend the write once +without it and with its fields in `body`. Older saved calls stay valid for recovery. Read the saved artifact for a stable snapshot instead of paging a changing live response. A text-window call against a live response above 24 KiB also returns that snapshot's artifact reference; continue on its content operation. A saved diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index e51d83ed205..f233015711e 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -1001,6 +1001,27 @@ describe("chat prompt policy", () => { expect(prompt).toContain("//agents/"); }); + // Full chat cohort 4 (onboarding.new-company): the CEO applied the missing-name rule and asked for a business name + // instead of sending the person to the board-only company-creation flow. + it("keeps the missing-name rule behind the board-only company-creation boundary", () => { + expect(AGENT_CHAT_DIRECTIVE).toContain("This name rule is for work you create yourself. It never comes before a board-only boundary"); + expect(AGENT_CHAT_DIRECTIVE).toContain("Otherwise link [Create organization](/onboarding), which opens the new-organization wizard"); + // Paperclip Cloud creates organizations in the Cloud portfolio; a bare /onboarding there opens the existing company's agent step. + expect(AGENT_CHAT_DIRECTIVE).toContain("when its response has a cloud field, this is a Paperclip Cloud stack, organizations are created in the person's Paperclip Cloud portfolio rather than in this app, so say that and do not link /onboarding"); + expect(AGENT_CHAT_DIRECTIVE).toContain("/onboarding takes no company prefix; //onboarding only adds an agent to this company"); + const nameRule = AGENT_CHAT_DIRECTIVE.indexOf("Only when none of these gives a name, ask for it before starting"); + expect(nameRule).toBeGreaterThan(-1); + expect(AGENT_CHAT_DIRECTIVE.indexOf("It never comes before a board-only boundary")).toBeGreaterThan(nameRule); + }); + // Full chat cohort 4 (app-permissions.ask-before-action): after a decline, the repeated request got "tell me to resubmit". + it("treats a repeated request after a declined approval card as an explicit retry that posts a fresh card", () => { + expect(AGENT_CHAT_DIRECTIVE).toContain("the new message is an explicit retry, not an ambiguous request: make the same call again so Paperclip posts a fresh approval card"); + expect(AGENT_CHAT_DIRECTIVE).toContain("Do not ask them to confirm the retry, tell them to resubmit, or treat the earlier decline as final."); + }); + // Full chat cohort 4 (tasks.ambiguous-task): the clarification card named both candidates in plain option labels. + it("puts candidate links in a clarification card's description, which renders links", () => { + expect(AGENT_CHAT_DIRECTIVE).toContain("If you ask with a question card instead, put the candidate links in the card's description"); + }); it("tells chat that instruction saves keep no history, so an undo with no listed revision asks instead of reconstructing", () => { expect(AGENT_CHAT_DIRECTIVE).toContain("Saving instructions does not record earlier versions"); expect(AGENT_CHAT_DIRECTIVE).toContain("an empty history means no earlier text is retained"); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 70f02e50c45..ab15de12539 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -410,6 +410,16 @@ describe("openapi routes", () => { for (const code of ["200", "401", "403"]) expect(operation.responses[code]).toBeDefined(); }); + // Full chat cohort 4: search_api listed the cancel route as open to agents, so an agent tried it and got 403. + it("documents question cancellation as board-only and points agents to withdraw", () => { + const spec = buildOpenApiSpec() as any; + const cancel = spec.paths["/api/issues/{id}/interactions/{interactionId}/cancel"].post; + expect(cancel["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(cancel.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(cancel.summary).toContain("POST /api/issues/{id}/interactions/{interactionId}/withdraw"); + expect(spec.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"].post["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" }); + }); + it("documents the owned pending-question read as canonical attention items without client scope overrides", () => { const spec = buildOpenApiSpec() as any; const operation = spec.paths["/api/issues/{id}/pending-questions"].get; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 8bb2fe0cdee..f6bac90e8d2 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1462,6 +1462,9 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/transfer", "POST /api/approvals/{id}/cancel", "POST /api/issues/{id}/interactions/{interactionId}/dismiss", + // Cancelling a question is board-only (routes/issues.ts rejects agent actors). Withdraw is not listed here: its + // route admits the card's creator agent and the task's assignee, so agents withdraw their own pending card there. + "POST /api/issues/{id}/interactions/{interactionId}/cancel", "POST /api/issues/{id}/retry", "GET /api/companies/{companyId}/prompt-injection-prevention", "PUT /api/companies/{companyId}/prompt-injection-prevention", @@ -1557,7 +1560,6 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/issues/{id}/interactions/{interactionId}/reject", "POST /api/issues/{id}/interactions/{interactionId}/respond", "POST /api/issues/{id}/interactions/{interactionId}/skip", - "POST /api/issues/{id}/interactions/{interactionId}/withdraw", "POST /api/companies/{companyId}/tools/examples/{id}/smoke", "POST /api/agents/me/connections/{connectionId}/token", "GET /api/tools/oauth/callback", @@ -11489,7 +11491,7 @@ registerCurrentRoute({ method: "post", path: "/api/issues/{id}/interactions/{interactionId}/cancel", tags: ["issues"], - summary: "Cancel an issue question interaction", + summary: "Cancel an issue question interaction (board users only; an agent withdraws its own pending card with POST /api/issues/{id}/interactions/{interactionId}/withdraw)", body: cancelIssueThreadInteractionSchema, }); diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index 5ad146fe31b..3e43d8b821d 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -90,7 +90,9 @@ export function isConversationReset(body: string): boolean { return body.trim() === "/new"; } -export const AGENT_CHAT_DIRECTIVE = `You are in an ongoing conversation with the user. Help them clarify the outcome they want. Ask focused questions when missing information materially affects the task; when the request is already clear, do not require a ritual confirmation. When a request to change one record matches several records you can access and nothing in the conversation selects one, ask which one before changing any of them: link each candidate, name what distinguishes them (such as project or status), and restate the requested change. Do not pick the first match, change every match, or keep searching for a hidden answer; asking in your reply is enough, and you finish the change when the person answers. When the person asks you to build or hand off something that must carry a name, such as the product or brand name shown on a landing page, use the name their request, this conversation or the work's saved brief gives; a project the person names in the request counts, so "Start a Launch project and build its landing page" carries the name Launch and needs no question. Only when none of these gives a name, ask for it before starting: use request_human_input with interactionKind: "questions" and one required free-text question, then end your turn. Do not take the name from unrelated records such as other existing projects or the company name. Do not build, delegate or brief the work with an invented or placeholder name; when the answer arrives, put it in the brief of the work you create. +export const AGENT_CHAT_DIRECTIVE = `You are in an ongoing conversation with the user. Help them clarify the outcome they want. Ask focused questions when missing information materially affects the task; when the request is already clear, do not require a ritual confirmation. When a request to change one record matches several records you can access and nothing in the conversation selects one, ask which one before changing any of them: link each candidate, name what distinguishes them (such as project or status), and restate the requested change. Do not pick the first match, change every match, or keep searching for a hidden answer; asking in your reply is enough, and you finish the change when the person answers. If you ask with a question card instead, put the candidate links in the card's description: its option labels show as plain text, without links. When the person asks you to build or hand off something that must carry a name, such as the product or brand name shown on a landing page, use the name their request, this conversation or the work's saved brief gives; a project the person names in the request counts, so "Start a Launch project and build its landing page" carries the name Launch and needs no question. Only when none of these gives a name, ask for it before starting: use request_human_input with interactionKind: "questions" and one required free-text question, then end your turn. Do not take the name from unrelated records such as other existing projects or the company name. Do not build, delegate or brief the work with an invented or placeholder name; when the answer arrives, put it in the brief of the work you create. This name rule is for work you create yourself. It never comes before a board-only boundary: creating a new company (organization) is a person's action that no agent credential can take, so do not ask for its name or other details first. Reply now: tell the person where they create it and repeat the business details they gave so they can enter them there. Where depends on the deployment, which GET /api/health shows: when its response has a cloud field, this is a Paperclip Cloud stack, organizations are created in the person's Paperclip Cloud portfolio rather than in this app, so say that and do not link /onboarding. Otherwise link [Create organization](/onboarding), which opens the new-organization wizard (also Create organization in the company switcher). /onboarding takes no company prefix; //onboarding only adds an agent to this company. + +When the person declined an approval card for an action and then asks for that same action again in a later message, the new message is an explicit retry, not an ambiguous request: make the same call again so Paperclip posts a fresh approval card for them to decide. Do not ask them to confirm the retry, tell them to resubmit, or treat the earlier decline as final. When changing defaults or policy for new or future work, update the resource that owns those defaults. Leave existing items and active work unchanged unless the user explicitly includes them in the requested change. This boundary includes metadata and association changes, even when they seem harmless or helpful. Do not broaden the request to align related existing items. Explain which subsequent work inherits the saved defaults and whether existing work changed. diff --git a/server/src/services/native-runtime/runner-api-client.ts b/server/src/services/native-runtime/runner-api-client.ts index 22d0dabbd46..9f41df7c50a 100644 --- a/server/src/services/native-runtime/runner-api-client.ts +++ b/server/src/services/native-runtime/runner-api-client.ts @@ -89,6 +89,15 @@ export function validateRunnerApiCall(value: unknown, context: RunnerApiContext) * Callers that reserve a mutation receipt must run this before the reservation. */ export function assertRunnerApiDispatchable(operation: RunnerApiOperation, input: RunnerApiCall, receivedKeys: readonly string[]): void { + assertBodyPresent(operation, input, receivedKeys); + // Like saveResponse, responseText is a GET read option. On a write it changes nothing, and models that send it + // there tend to put request fields beside it instead of in body; refuse before sending and say where they go. + if (operation.method !== "GET" && receivedKeys.includes("responseText")) { + throw badRequest(`This request was not sent: responseText applies only to GET reads and never carries request fields. Resend ${operation.operationId} once without responseText, with its request fields in body${requestFieldHint(operation)}.`); + } +} + +function assertBodyPresent(operation: RunnerApiOperation, input: RunnerApiCall, receivedKeys: readonly string[]): void { if (input.body !== undefined || input.files?.length || !operation.requestBody?.required) return; // A PATCH without a body succeeds as a no-op that reads like a completed // update. A POST or PUT without a body whose schema names required fields diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts index 53b8de560fd..5d9a3055ce6 100644 --- a/server/src/services/native-runtime/runner-api.test.ts +++ b/server/src/services/native-runtime/runner-api.test.ts @@ -811,15 +811,23 @@ describe("runner API request boundary", () => { await expect(executeRunnerApi({ operationId: createProject, body: { name: "Once" }, responseText: { offsetBytes: 4 } }, context, io(request))).rejects.toThrow("This request was not sent"); expect(request).not.toHaveBeenCalled(); }); - it.each([{}, { limitBytes: 8192 }, { offsetBytes: 0, limitBytes: 4096 }])("sends a mutation once when responseText asks only for the first window: %j", async responseText => { + // Full chat cohort 4: write calls carried responseText beside (or instead of) their body. Like saveResponse, a new + // write with responseText is refused before sending; an older saved call still validates for recovery. + it.each([{}, { limitBytes: 8192 }, { offsetBytes: 0, limitBytes: 4096 }])("refuses a new write that carries responseText before sending: %j", async responseText => { const request = vi.fn(async () => Response.json({ id: "company-a", name: "Acorn Studio" })); - const result = await executeRunnerApi({ operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" }, body: { name: "Acorn Studio" }, responseText }, context, io(request)); + const call = { operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" }, body: { name: "Acorn Studio" }, responseText }; + const error = await executeRunnerApi(call, context, io(request)).then(() => null, (caught: Error) => caught); + expect(request).not.toHaveBeenCalled(); + expect(error?.message).toContain("This request was not sent: responseText applies only to GET reads and never carries request fields."); + expect(error?.message).toContain("Resend PATCH /api/companies/{companyId} once without responseText, with its request fields in body"); + expect(validateRunnerApiCall(call, context).input).not.toHaveProperty("responseText"); + }); + it("still sends the same write once without responseText", async () => { + const request = vi.fn(async () => Response.json({ id: "company-a", name: "Acorn Studio" })); + const result = await executeRunnerApi({ operationId: "PATCH /api/companies/{companyId}", pathParams: { companyId: "company-a" }, body: { name: "Acorn Studio" } }, context, io(request)); expect(request).toHaveBeenCalledTimes(1); - const [, init] = request.mock.calls[0]!; - expect(init?.method).toBe("PATCH"); - expect(JSON.parse(String(init?.body))).toEqual({ name: "Acorn Studio" }); + expect(JSON.parse(String(request.mock.calls[0]![1]?.body))).toEqual({ name: "Acorn Studio" }); expect(result).toMatchObject({ ok: true, status: 200, data: { name: "Acorn Studio" } }); - expect(result).not.toHaveProperty("responseText"); }); it("rejects a PATCH with no body instead of sending a no-op update", async () => { const request = vi.fn(); diff --git a/server/src/services/tool-action-delivery.ts b/server/src/services/tool-action-delivery.ts index 92db1431888..62365177ae3 100644 --- a/server/src/services/tool-action-delivery.ts +++ b/server/src/services/tool-action-delivery.ts @@ -241,7 +241,7 @@ export function toolActionDeliveryService( request.status === "executed" ? "The approved action already ran. Do not call it again; continue with the recorded result. Process the result and answer the user in your own words. Do not paste the raw tool or transport JSON unless the user asks for it." : request.status === "rejected" - ? "The human declined this action. Do not retry the same call. Adjust your approach or explain what is blocked." + ? "The human declined this action. Do not retry the same call on your own. Adjust your approach or explain what is blocked. If the person asks for the same action again in a later message, that is an explicit retry: make the call again so a fresh approval card is posted." : request.status === "expired" || request.status === "cancelled" ? "The review is no longer available. Do not execute the stored request. Explain the recorded outcome before requesting another review." diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 54ebbfb0974..80e88955423 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -8970,7 +8970,7 @@ export function createToolGatewayService( invocationId: invocation.id, actionRequestId: actionRequest.id, instructions: - "The action was declined. Do not retry the same call; adjust your approach or report the decline on the task.", + "The action was declined. Do not retry the same call on your own; adjust your approach or report the decline on the task. If the person asks for the same action again in a new message, the call made for that message posts a fresh approval card.", }, ); }