mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Cases is an experimental feature; an instance admin turns it on in
Experimental settings
> - When Cases is off, every case and workflow route answers 403 "Cases
are disabled" with no other information
> - A chat agent asked to "open a customer case" could not tell whether
the denial was final or what to offer
> - In one eval run it created a project and a task in place of the
case; in another it spent most of its turn searching routes before it
replied
> - This pull request gives the denial a code and a final, actionable
remediation, like the board-only denial
> - The benefit is a fast, truthful reply that names the setting and
lets the person choose
## Linked Issues or Issue Description
**What happened**
In the chat eval cases.cases-disabled ("Open a customer case for this
bug." with Cases off), the agent got 403 "Cases are disabled". In one
run it then created a project and a bug task without asking. In a later
run it searched many routes and instance settings for about 100 seconds
before it replied.
**Expected behavior**
The denial says that Cases is off, that the denial is final, where an
instance admin turns it on, and that the agent must not create a
substitute record before the person chooses. The agent replies at once
and offers the setting or an ordinary task.
**Steps to reproduce**
1. Turn off Cases in Experimental settings.
2. As an agent, call GET /api/companies/{companyId}/cases.
3. See a bare 403 "Cases are disabled" with no code or remediation.
## What Changed
- Rebased on #20894, which already gave the case routes a
`cases_disabled` code, a final remediation and the hidden-settings
check. This PR moves that remediation into one helper used by every case
and workflow path, and adds: no substitute task or project before the
person chooses, and the offer of an ordinary task.
- New `casesDisabledError()` in `server/src/services/cases-disabled.ts`:
same 403 message, plus `code: "cases_disabled"` and a remediation. The
remediation says the denial is final, forbids a substitute task or
project before the person chooses, links `[Experimental
settings](/<prefix>/company/settings/instance/experimental)`, and offers
an ordinary task.
- Native tool results carry only the error message, so the native case
tools, workflow tools and runner tool authority inline the remediation
in the message (`casesDisabledError({ inlineRemediation: true })`).
- Case routes, workflow service, case handoffs, native case tools,
native workflow tools and the runner tool authority use the helper
instead of a bare `forbidden("Cases are disabled")`.
- Tests: `cases-routes.test.ts` checks the code and remediation;
`native-case-tools.integration.test.ts` checks the native message
carries the remediation (red on master, green here).
## Verification
- `npx vitest run src/__tests__/cases-routes.test.ts
src/services/case-handoffs.test.ts
src/services/native-runtime/native-case-tools.integration.test.ts
src/__tests__/heartbeat-case-handoff.test.ts` (166 passed).
- The cases.cases-disabled chat eval is rerun on a temporary branch;
results are recorded in the PR conversation.
## Risks
- Low risk. The status and message do not change. The error body adds
`code` and `remediation`.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), 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 <noreply@anthropic.com>
86 lines
5.1 KiB
TypeScript
86 lines
5.1 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { cases, type Db } from "@paperclipai/db";
|
|
import { z } from "zod";
|
|
import { caseInitialDocumentsSchema, caseWorkflowDocumentTextSchema, isUuidLike } from "@paperclipai/shared";
|
|
import { patchCaseSchema } from "../../routes/cases-schemas.js";
|
|
import { badRequest, notFound } from "../../errors.js";
|
|
import { casesDisabledError } from "../cases-disabled.js";
|
|
import { assertActorCanReadCase } from "../case-access.js";
|
|
import type { AuthorizationActor } from "../authorization.js";
|
|
import { instanceSettingsService } from "../instance-settings.js";
|
|
import { caseReadPageShape, refineCaseReadPage } from "../case-agent-read.js";
|
|
import type { RunnerApiCall } from "./runner-api-client.js";
|
|
|
|
export const NATIVE_CASE_OPERATIONS = new Set([
|
|
"create_case", "read_case", "update_case", "list_workflows", "read_workflow", "create_workflow", "update_workflow",
|
|
]);
|
|
|
|
/** Retain the case boundary on generic HTTP response captures too. Shared
|
|
* pipeline routes deliberately do not establish a standalone-case identity. */
|
|
export async function caseIdForApiCapture(db: Db, companyId: string, path: string, pathParams?: Record<string, string>) {
|
|
const parameter = /^\/api\/cases\/\{([^}]+)\}(?:\/|$)/.exec(path)?.[1];
|
|
const identity = parameter ? pathParams?.[parameter] : undefined;
|
|
if (!identity) return undefined;
|
|
const [record] = await db.select({ id: cases.id }).from(cases).where(and(eq(cases.companyId, companyId),
|
|
isUuidLike(identity) ? eq(cases.id, identity) : eq(cases.identifier, identity.trim().toUpperCase()),
|
|
)).limit(1);
|
|
return record?.id;
|
|
}
|
|
|
|
const identity = z.object({ caseId: z.string().guid().optional() }).strict();
|
|
const retryKey = z.string().min(1).max(240);
|
|
export const createNativeCaseSchema = z.object({
|
|
idempotencyKey: retryKey, caseType: z.string().trim().min(1).max(120), title: z.string().trim().min(1).max(500),
|
|
summary: z.string().max(8000).optional(), fields: z.record(z.string(), z.unknown()).optional(),
|
|
projectId: z.string().guid().nullable().optional(),
|
|
workflow_document_text: caseWorkflowDocumentTextSchema.optional(),
|
|
workflowId: z.string().guid().optional(),
|
|
kickoff: z.object({ toAgentId: z.string().guid(), phase: z.string().trim().min(1).max(120), instructions: z.string().min(1).max(20000) }).strict(),
|
|
initialDocuments: caseInitialDocumentsSchema.optional(),
|
|
}).strict().refine(input => Boolean(input.workflowId) !== (input.workflow_document_text !== undefined), "Provide workflowId or workflow_document_text, not both");
|
|
|
|
const schemas = {
|
|
read_case: identity.extend(caseReadPageShape).superRefine(refineCaseReadPage),
|
|
update_case: patchCaseSchema.pick({ title: true, summary: true, fields: true })
|
|
.extend({ caseId: z.string().guid().optional(), idempotencyKey: retryKey })
|
|
.strict().refine(input => ["title", "summary", "fields"].some(key => Object.hasOwn(input, key)),
|
|
"Provide at least one of title, summary, or fields"),
|
|
};
|
|
|
|
/** A narrow transport over the real case routes, including their privacy, trust,
|
|
* mutation fences, retained sources and audit. Never resolves pipeline cases. */
|
|
export async function executeNativeCaseTool(input: {
|
|
db: Db;
|
|
binding: { companyId: string; issueId: string; casesEnabled?: boolean };
|
|
actor: AuthorizationActor;
|
|
tool: string;
|
|
arguments: unknown;
|
|
callApi: (receiptKey: string, request: RunnerApiCall) => Promise<unknown>;
|
|
}) {
|
|
if (input.binding.casesEnabled !== true || !(await instanceSettingsService(input.db).getExperimental()).enableCases) {
|
|
throw casesDisabledError({ inlineRemediation: true });
|
|
}
|
|
const schema = schemas[input.tool as keyof typeof schemas];
|
|
if (!schema) throw badRequest("Unknown native case operation");
|
|
const parsed = schema.safeParse(input.arguments);
|
|
if (!parsed.success) throw badRequest("Invalid case tool arguments", { issues: parsed.error.issues });
|
|
const values = parsed.data as { caseId?: string; idempotencyKey?: string } & Record<string, unknown>;
|
|
const [caseRow] = await input.db.select({ id: cases.id, companyId: cases.companyId }).from(cases).where(and(
|
|
eq(cases.companyId, input.binding.companyId), values.caseId ? eq(cases.id, values.caseId) : eq(cases.workIssueId, input.binding.issueId),
|
|
)).limit(1);
|
|
if (!caseRow) throw notFound("Case not found");
|
|
await assertActorCanReadCase(input.db, input.actor, caseRow);
|
|
const { caseId: _caseId, idempotencyKey, ...body } = values;
|
|
const mutation = input.tool === "update_case";
|
|
const method = mutation ? "PATCH" : "GET";
|
|
const requestKey = idempotencyKey ?? createHash("sha256").update(JSON.stringify(body)).digest("hex");
|
|
const result = await input.callApi(`case-tool:${input.tool}:${requestKey}`, {
|
|
operationId: `${method} /api/cases/{id}`, pathParams: { id: caseRow.id },
|
|
query: { includeDocumentBodies: false, ...(!mutation ? { agentRead: true, ...body } : {}) }, ...(mutation ? { body } : {}),
|
|
});
|
|
if (!result || typeof result !== "object" || Array.isArray(result)) return result;
|
|
const { operationId, ...response } = result as Record<string, unknown>;
|
|
return { ...response, apiOperationId: operationId };
|
|
}
|