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.`;