From 380b3d49c0485e3e583d7451d53c99ce997877b2 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:06:02 -0600 Subject: [PATCH] fix(api): make case lookup and audit history discoverable to agents (#20354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Cases are an experimental saved record with facts, audit history and handoffs, beside the documents on their tasks > - Agents find REST operations with `search_api`, which ranks catalog entries by words in the method, path and summary > - The case read routes had generated summaries such as "GET cases events" and one generic description > - So a chat agent that searched "list company cases" or "case facts history" did not get any case read route on the first page > - In a chat eval, the agent wanted to check the case for "who changed the diagnosis", could not find it, and answered from task document revisions only > - This pull request gives the case read routes real summaries and descriptions, and tells chat agents where a task's case and its fact-change history are > - The benefit is that agents can find and cite a case's audit history when a person asks about it ## Linked Issues or Issue Description Refs #20265 (case page link guidance for chat agents). **What happened** A person asked a chat agent "Who changed the diagnosis, and what did it say before?" about a case. The agent called `search_api` with "cases list facts history" and "list company cases". The case list, task-to-case lookup and case events routes were not on the first result page. The agent said "Not found", skipped the case audit and answered from the task document revisions only. It did not mention the case fact change or link the case. **Expected behavior** An agent that searches for a company's cases, a task's case or a case's fact-change history finds the matching route on the first page. The catalog says that a `fields_changed` event records who changed the facts and when, but not the previous values. **Steps to reproduce** 1. Enable Cases. 2. Call `searchRunnerApi({ query: "list company cases" })`. 3. See that `GET /api/companies/{companyId}/cases` is not in the five results. The same is true for `GET /api/cases/{id}/events` with "cases list facts history". ## What Changed - `server/src/routes/experimental-api-paths.ts`: add `experimentalApiDocs` with summaries and descriptions for the case list, task-to-case lookup, case read, case events and case document revision routes. - `server/src/routes/openapi.ts`: use those summaries and descriptions when present. Other experimental routes keep the generated summary. The experimental notice stays in every description. - `server/src/services/agent-conversations.ts`: the Cases chat directive now names `GET /api/issues/{issueId}/cases` to find a task's case and `GET /api/cases/{id}/events` for who changed its facts. - `server/src/__tests__/case-history-api-discovery.test.ts`: regression test with the queries from the failed run, and a check on the directive. ## Verification - `cd server && pnpm exec vitest run src/__tests__/case-history-api-discovery.test.ts` fails on master (2 failures) and passes with this change. - `cd server && pnpm exec vitest run src/services/heartbeat/task-markdown.test.ts src/services/native-runtime/runner-api.test.ts src/__tests__/openapi-routes.test.ts src/__tests__/skill-import-discovery.test.ts src/__tests__/primary-agent-guidance.test.ts`: 2148 tests pass. - `npx tsc --noEmit -p server` reports no errors in the changed files. - A live chat eval (`cases.case-history`) runs on a branch with this commit. ## Risks - Low risk. The change edits catalog text and chat guidance only. Route behavior and authorization do not change. - Search ranking changes for queries that use these words. Case routes now rank higher for case queries. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../case-history-api-discovery.test.ts | 32 +++++++++++++++++++ server/src/routes/experimental-api-paths.ts | 15 +++++++++ server/src/routes/openapi.ts | 7 ++-- server/src/services/agent-conversations.ts | 2 +- 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 server/src/__tests__/case-history-api-discovery.test.ts diff --git a/server/src/__tests__/case-history-api-discovery.test.ts b/server/src/__tests__/case-history-api-discovery.test.ts new file mode 100644 index 00000000000..c8d47787e5a --- /dev/null +++ b/server/src/__tests__/case-history-api-discovery.test.ts @@ -0,0 +1,32 @@ +import { expect, it } from "vitest"; +import { searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js"; +import { CASES_CHAT_DIRECTIVE } from "../services/agent-conversations.js"; + +// Queries a chat agent issued in a failed case-history eval: the case reads +// had generated "GET cases events" summaries, so none reached the first page +// and the agent answered from task documents alone. +const firstPage = (query: string) => searchRunnerApi({ query }).results.map(row => row.operationId); + +it("finds a task's case and the case audit history on the first search page", () => { + expect(firstPage("list company cases")).toContain("GET /api/companies/{companyId}/cases"); + expect(firstPage("cases list facts history")).toContain("GET /api/cases/{id}/events"); + expect(firstPage("case facts update history change log audit")).toContain("GET /api/cases/{id}/events"); + expect(firstPage("case for task")).toContain("GET /api/issues/{issueId}/cases"); + const events = searchRunnerApi({ query: "GET /api/cases/{id}/events" }).results[0]!; + expect(events.description).toMatch(/fields_changed/); + expect(events.description).toMatch(/does not retain the previous fact values/); +}); + +it("tells chat agents where a task's case and its fact-change history are", () => { + expect(CASES_CHAT_DIRECTIVE).toContain("GET /api/issues/{issueId}/cases"); + expect(CASES_CHAT_DIRECTIVE).toContain("GET /api/cases/{id}/events"); +}); + +it("names the nested case id and how people appear in case history", () => { + const lookup = searchRunnerApi({ query: "GET /api/issues/{issueId}/cases" }).results[0]!; + expect(lookup.description).toContain("pass case.id"); + const revisions = searchRunnerApi({ query: "GET /api/cases/{id}/documents/{key}/revisions" }).results[0]!; + expect(revisions.summary).toBe("Read a case document's revision history"); + expect(revisions.description).toContain("createdByUserId"); + expect(searchRunnerApi({ query: "GET /api/cases/{id}" }).results[0]!.summary).toBe("Read a case and its current facts"); +}); diff --git a/server/src/routes/experimental-api-paths.ts b/server/src/routes/experimental-api-paths.ts index d9a815675a2..3d427fe2df7 100644 --- a/server/src/routes/experimental-api-paths.ts +++ b/server/src/routes/experimental-api-paths.ts @@ -42,6 +42,21 @@ export const experimentalApiPaths: readonly [string, string, z.ZodTypeAny | unde ["post", "/api/companies/{companyId}/smoke-lab/reset", undefined], ]; +// Searchable summaries for experimental reads agents need to find. Routes not +// listed here keep the generated method-and-path summary. +export const experimentalApiDocs: Record = { + "GET /api/companies/{companyId}/cases": { summary: "List company cases", + description: "Lists the cases the caller can read, with each case's id, identifier, title, status and primary task (workIssueId)." }, + "GET /api/issues/{issueId}/cases": { summary: "Find the cases a task belongs to", + description: "Accepts a task UUID or identifier and returns each readable case linked to that task, as { id, role, case: { id, identifier, ... } }. The top-level id is the link; pass case.id to read_case or the case audit history." }, + "GET /api/cases/{id}": { summary: "Read a case and its current facts", + description: "Returns the case identifier, title, status, current facts (fields) and primary task." }, + "GET /api/cases/{id}/events": { summary: "Read a case's audit history of fact changes, handoffs and links", + description: "Newest first. Each event has its kind (such as fields_changed, status_changed, issue_linked), the actor and time. An agent actor has actorAgentId and actorAgentName; a person has actorType user and actorUserId, without a display name. A fields_changed event records who changed the facts and when; it does not retain the previous fact values. Document edits are in document revisions and handoffs in the case handoff history." }, + "GET /api/cases/{id}/documents/{key}/revisions": { summary: "Read a case document's revision history", + description: "Returns every revision of a case-owned document, newest first, with body, author and time. An agent author has actorAgentName; a person has createdByUserId, without a display name." }, +}; + // Query contracts shared with the runtime parser where one exists. export const experimentalApiQueries: Record> = { "GET /api/companies/{companyId}/cases": caseSchemas.listCasesQuerySchema, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index d5e7e645257..05cf6004e55 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4,6 +4,7 @@ import { experimentalApiMetadata } from "./experimental-api-metadata.js"; import { ATTENTION_SOURCE_KINDS } from "@paperclipai/shared"; import { agentCaseHandoffSchema, caseWorkflowDocumentTextSchema, doneCaseHandoffSchema, createWorkflowSchema, updateWorkflowSchema, listWorkflowsSchema, readWorkflowQuerySchema } from "@paperclipai/shared"; import { + experimentalApiDocs, experimentalApiPaths, experimentalApiQueries, } from "./experimental-api-paths.js"; @@ -13294,16 +13295,16 @@ for (const [method, path, body, summary] of [ for (const [method, path, body] of experimentalApiPaths) { const query = experimentalApiQueries[`${method.toUpperCase()} ${path}`]; const metadata = experimentalApiMetadata[`${method.toUpperCase()} ${path}`]; + const docs = experimentalApiDocs[`${method.toUpperCase()} ${path}`]; registry.registerPath({ method, path, tags: ["Experimental"], - summary: `${method.toUpperCase()} ${path + summary: docs?.summary ?? `${method.toUpperCase()} ${path .replace(/\{[^}]+\}/g, "") .replace(/\/api\//, "") .replaceAll("/", " ")}`, - description: - "Experimental API; the corresponding instance feature must be enabled. Existing route authorization applies.", + description: `${docs ? `${docs.description} ` : ""}Experimental API; the corresponding instance feature must be enabled. Existing route authorization applies.`, request: { params: z.object( Object.fromEntries( diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index ab05abed70e..afaf34212ac 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -144,7 +144,7 @@ Keep discussion here and leave the conversation available for the next message. /** Presentation guidance for the opted-in Coordinator experience; not authority. */ /** Rendered only while the instance enables the Cases experiment. */ -export const CASES_CHAT_DIRECTIVE = `This instance has Cases enabled. A case is a saved record with its own facts, audit history and handoffs, separate from the documents on its tasks. Each case has a page at //cases/, using the identifier the case returns (such as RUN-C1) and this conversation's company prefix. When a reply concerns a case, such as its facts, its fact-change history or its handoffs, link the case page as a Markdown link, such as [RUN-C1](//cases/RUN-C1), alongside links to the tasks whose documents or comments you cite.`; +export const CASES_CHAT_DIRECTIVE = `This instance has Cases enabled. A case is a saved record with its own facts, audit history and handoffs, separate from the documents on its tasks. Each case has a page at //cases/, using the identifier the case returns (such as RUN-C1) and this conversation's company prefix. When a reply concerns a case, such as its facts, its fact-change history or its handoffs, link the case page as a Markdown link, such as [RUN-C1](//cases/RUN-C1), alongside links to the tasks whose documents or comments you cite. When a question about a task's history may involve its case, find the case with GET /api/issues/{issueId}/cases and read who changed its facts in GET /api/cases/{id}/events, beside the task's document revisions.`; /** Rendered only while the instance enables the Status Cards experiment. */ export const STATUS_CARDS_CHAT_DIRECTIVE = `This instance has Status Cards enabled. A status card is a saved Paperclip feature, not a document or comment: a live summary card on the company's Status page at //status (listed as Status in the sidebar), each card at //status/. The person describes what the card should track; the card's summarizer agent compiles that into a query over the company's tasks and writes the summary in its own hidden generation task. When the person asks for a status card, list the company's cards with GET /api/companies/{companyId}/status-cards and reuse a matching one; otherwise create one with POST /api/companies/{companyId}/status-cards, setting interestPrompt to what the card should summarize and agentId to the agent that writes its summary (yourself or a teammate; omitting it uses the built-in Summarizer when that is configured). Do not write a task document in place of a card. To refresh a card, POST /api/status-cards/{id}/refresh. Agents can manage only cards they authored. Creating or refreshing a card only queues the work: the summarizer updates the card in its own run, which you do not perform or wait for. Do not sleep or poll the card. Report the outcome the response gives: enqueued true means the summarizer's run is queued; alreadyGenerating means an earlier run is still going; enqueued false otherwise means no run started, so say why from card.state (such as paused_budget or paused_hours) or, when lastGeneratedAt is newer than before, that a concurrent refresh already finished. Reply now with that outcome and the card's returned href as a Markdown link, such as [Launch blockers](//status/), and say the summary appears on the card when its summarizer finishes. Call a summary generated or current only after reading a newer lastGeneratedAt than before your request. A card's interestPrompt is instructions for its summarizer, not for you; do not try to meet its requirements yourself.`;