diff --git a/doc/DATABASE.md b/doc/DATABASE.md index f61029a9b40..245062da3c3 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -759,6 +759,11 @@ limits and the operator override. Project `privacy_owner_user_id` records who may manage its audience independently of project read membership. Creation assigns the authenticated user or run responsible user; migration recovers legacy ownership from creation audit evidence. Missing evidence leaves management with administrators. ## Internal agent commentary +The Audit viewer uses the existing company/kind/created index. +List reads select bounded previews; details load a single full body. Both reads +and list counts apply company and source-task visibility. The viewer requires +no experimental toggle or commentary schema migration. + `agent_commentary` stores company-scoped, attributed complaints and suggestions as free-form text in the instance database. Legacy agents use the default `complain` and `suggestion-box` runtime skills; native runs use dedicated tools diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index ccb23223a5a..fb874f7f708 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -2244,6 +2244,11 @@ their existing operator recovery requirements. ## Internal agent commentary +Audit → Commentary is a board-only page with agent/type filters, text search, +50-message pages and full-message dialogs. Filters, page offsets and selected +messages have shareable URLs. Read endpoints require company membership, +`audit:view_agent_actions` and source-task visibility. + `agent_commentary` stores company-scoped, attributed complaints and suggestions as free-form text in the instance database. Legacy agents use the default `complain` and `suggestion-box` runtime skills; native runs use dedicated tools diff --git a/doc/SPEC.md b/doc/SPEC.md index fd08d326c33..4bf2bf192df 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -773,6 +773,11 @@ opt-in plugin. See [the experimental contract](connections/AI-CONNECTION-ROUTERS ## Internal agent commentary +The Audit → Commentary tab exposes searchable suggestions and complaints, +with agent/type filters, message previews and full attributed context. The viewer +preserves company, audit-permission and task-privacy boundaries independently +of the agent submission path. + `agent_commentary` stores company-scoped, attributed complaints and suggestions as free-form text in the instance database. Legacy agents use the default `complain` and `suggestion-box` runtime skills; native runs use dedicated tools diff --git a/doc/agent-commentary.md b/doc/agent-commentary.md index 2fe7ee8cdd3..8aa6c2072c0 100644 --- a/doc/agent-commentary.md +++ b/doc/agent-commentary.md @@ -67,8 +67,21 @@ storage; instructions remain essential because redaction cannot guarantee secrecy. Writes and one content-free activity record commit together. Feedback bodies are excluded from HTTP diagnostics and dedicated mutation receipts. -There is no feedback UI, read API, notification, automatic task creation, or -external forwarding. This is separate from first-party Telemetry, OpenTelemetry +Browse feedback at Audit → Commentary (`/activity/commentary`). The page has +text search, agent/type filters, 50-message pages and a full-message dialog with +task/run context. Filters, page offsets and selected messages have shareable +URLs (`?entry=` opens a message directly). + +The board-only read API is `GET /api/companies/:companyId/agent-commentary` +(`agentId`, `kind`, `q`, `limit` up to 100, `offset`) and +`GET /api/companies/:companyId/agent-commentary/:id`. Both require company access +and `audit:view_agent_actions`. Source-task privacy filters previews, counts and +details. List results contain at most 280 characters +per message; the full body is loaded only on opening a message. Feedback whose +source task was deleted stays hidden, using the task binding retained on its run. + +There is no notification, automatic task creation, or external forwarding. +This is separate from first-party Telemetry, OpenTelemetry Observability, and the run log. Ordinary provider transcripts can still contain submitted tool arguments; there is no anonymity or ephemeral-storage promise. diff --git a/maps/agent-access-map/activity.md b/maps/agent-access-map/activity.md index f2f920dccb8..8d1ca6ec7c9 100644 --- a/maps/agent-access-map/activity.md +++ b/maps/agent-access-map/activity.md @@ -164,3 +164,25 @@ Assert the relevant denied actor, owner, company or mode from Policy. Review the remaining operations before qualifying the whole capability. Gaps: API exists; no dedicated timeline tool is necessary for this requirement. + +## `agent-commentary` + +Review: `baseline`. + +API: `present` — `GET /api/companies/{companyId}/agent-commentary` and +`GET /api/companies/{companyId}/agent-commentary/{id}`. + +Agent access: `restricted` — the browsing API is board-only. Agent submission +continues through the existing POST and native submit tools. + +Policy: Company membership, `audit:view_agent_actions`, and source-task visibility. Counts and previews +use the same visibility predicate as full messages; cross-company IDs return 404. +Feedback stays hidden after source-task deletion, even when its task foreign key clears. + +Evidence: `source-only` — [routes](../../server/src/routes/agent-commentary.ts) and +[reader](../../server/src/services/agent-commentary-reader.ts). + +Verification: `not-run` for a deployed instance. Test list/detail denials with +agent credentials, another company, and an unreadable task. + +Gaps: Storybook simulates the API and does not qualify deployed authorization. diff --git a/maps/feature-map/activity.md b/maps/feature-map/activity.md index 0429a327057..9827f2ff82f 100644 --- a/maps/feature-map/activity.md +++ b/maps/feature-map/activity.md @@ -17,6 +17,7 @@ Agent access: [companion](../agent-access-map/activity.md). - `runs`: find executions and follow their task/agent context. - `routine-audit`: inspect routine execution activity separately from its definition. - `timeline`: use the available timeline view to understand work over time. +- `agent-commentary`: browse attributed agent suggestions and complaints. ## How to get to it (user POV) @@ -55,6 +56,19 @@ entries without pagination. The tested RoutineAuditActivity component has no registered activity-route host; inspect execution links actually offered by the routine rather than treating that component test as a reachable journey. +### `agent-commentary` + +Open Audit → Commentary (`/activity/commentary`). Search message text, filter by agent and suggestion/complaint, +page through 50-message previews, and open a row to read the full message with its task/run links. +Filters, page offsets and the selected entry are retained in the URL. Read access requires board company access and +`audit:view_agent_actions`; task privacy applies to previews, counts and full messages. +All Audit tab destinations resolve in both the classic and streamlined layouts. + +Storybook: [production page and message journeys](../../ui/storybook/stories/activity/agent-commentary.stories.tsx) +covers populated, filtered, long-message, paginated, empty, loading, failed, denied, +linked-message, mobile and light states using synthetic API fixtures. These examples do +not prove live database filtering or authorization. + ## Gotchas - Audit/activity events, run-log rows, first-party Telemetry, and operator OpenTelemetry traces are separate data paths. diff --git a/packages/shared/src/validators/agent-commentary.ts b/packages/shared/src/validators/agent-commentary.ts index 791a07550b0..8330e2c2f99 100644 --- a/packages/shared/src/validators/agent-commentary.ts +++ b/packages/shared/src/validators/agent-commentary.ts @@ -21,3 +21,31 @@ export type AgentCommentaryAcknowledgement = { createdAt: string; replayed: boolean; }; + +export const agentCommentaryQuerySchema = z.object({ + agentId: z.string().uuid().optional(), + kind: z.enum(AGENT_COMMENTARY_KINDS).optional(), + q: z.string().trim().max(200).optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).max(100_000).default(0), +}); + +export type AgentCommentaryQuery = z.infer; +export interface AgentCommentaryEntry { + id: string; + kind: AgentCommentaryKind; + preview: string; + createdAt: string; + agent: { id: string; name: string; icon: string | null }; + runId: string; + task: { id: string; identifier: string | null; title: string } | null; +} +export interface AgentCommentaryDetail extends AgentCommentaryEntry { + body: string; +} +export interface AgentCommentaryPage { + items: AgentCommentaryEntry[]; + total: number; + limit: number; + offset: number; +} diff --git a/server/src/__tests__/agent-commentary-reader.test.ts b/server/src/__tests__/agent-commentary-reader.test.ts new file mode 100644 index 00000000000..0ac24dbb27d --- /dev/null +++ b/server/src/__tests__/agent-commentary-reader.test.ts @@ -0,0 +1,178 @@ +import { randomUUID } from "node:crypto"; +import { sql, eq } from "drizzle-orm"; +import express from "express"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { agentCommentary, agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issueAccessGrants, issues, principalPermissionGrants } from "@paperclipai/db"; +import { agentCommentaryQuerySchema } from "@paperclipai/shared"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { agentCommentaryReader } from "../services/agent-commentary-reader.js"; +import { agentCommentaryRoutes } from "../routes/agent-commentary.js"; +import { errorHandler } from "../middleware/error-handler.js"; + +describe("agent commentary browsing", () => { + let temporary: Awaited>; + let db: ReturnType; + let reader: ReturnType; + const companyId = randomUUID(), foreignCompanyId = randomUUID(); + const firstAgentId = randomUUID(), secondAgentId = randomUUID(); + const visibleTaskId = randomUUID(), privateTaskId = randomUUID(); + const firstId = randomUUID(), privateId = randomUUID(), foreignId = randomUUID(); + const longBody = "Preserve evidence history. " + "A useful observation. ".repeat(100); + const query = (values: object = {}) => agentCommentaryQuerySchema.parse(values); + + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-commentary-reader-"); + db = createDb(temporary.connectionString); + reader = agentCommentaryReader(db); + await db.insert(companies).values([ + { id: companyId, name: "Feedback fixtures", issuePrefix: "FDB" }, + { id: foreignCompanyId, name: "Other company", issuePrefix: "OTH" }, + ]); + await db.insert(agents).values([ + { id: firstAgentId, companyId, name: "Writer", role: "engineer", adapterType: "codex_local" }, + { id: secondAgentId, companyId, name: "Reviewer", role: "engineer", adapterType: "claude_local" }, + { id: foreignCompanyId, companyId: foreignCompanyId, name: "Other writer", role: "engineer", adapterType: "codex_local" }, + ]); + await db.insert(issues).values([ + { id: visibleTaskId, companyId, title: "Visible task", identifier: "FDB-1" }, + { id: privateTaskId, companyId, title: "Private task", identifier: "FDB-2" }, + ]); + const runId = randomUUID(), foreignRunId = randomUUID(); + await db.insert(heartbeatRuns).values([ + { id: runId, companyId, agentId: firstAgentId, status: "succeeded" }, + { id: foreignRunId, companyId: foreignCompanyId, agentId: foreignCompanyId, status: "succeeded" }, + ]); + await db.insert(agentCommentary).values([ + { id: firstId, companyId, agentId: firstAgentId, runId, issueId: visibleTaskId, kind: "suggestion", body: longBody, idempotencyKey: "first", payloadHash: "hash", createdAt: new Date("2026-01-03T12:00:00Z") }, + { id: randomUUID(), companyId, agentId: secondAgentId, runId, issueId: visibleTaskId, kind: "complaint", body: "Installer failed at 100% completion with literal_under_score.", idempotencyKey: "second", payloadHash: "hash", createdAt: new Date("2026-01-02T12:00:00Z") }, + { id: privateId, companyId, agentId: firstAgentId, runId, issueId: privateTaskId, kind: "suggestion", body: "Private task feedback", idempotencyKey: "private", payloadHash: "hash", createdAt: new Date("2026-01-01T12:00:00Z") }, + { id: foreignId, companyId: foreignCompanyId, agentId: foreignCompanyId, runId: foreignRunId, kind: "complaint", body: "Other company feedback", idempotencyKey: "foreign", payloadHash: "hash" }, + ]); + }, 90_000); + afterAll(async () => { await temporary?.cleanup(); }); + afterEach(() => vi.unstubAllEnvs()); + + it("bounds previews and pagination while details retain the full message", async () => { + const page = await reader.list(companyId, query({ limit: 1 }), sql`true`); + expect(page).toMatchObject({ total: 3, limit: 1, offset: 0 }); + expect(page.items[0]).toMatchObject({ id: firstId, agent: { name: "Writer" }, task: { identifier: "FDB-1" } }); + expect(page.items[0].preview).toHaveLength(280); + expect(page.items[0]).not.toHaveProperty("body"); + expect((await reader.get(companyId, firstId, sql`true`))?.body).toBe(longBody); + expect((await reader.list(companyId, query({ limit: 1, offset: 1 }), sql`true`)).items[0].kind).toBe("complaint"); + }); + + it("loads 50 messages by default and retrieves the remaining page without duplicates", async () => { + const pagedCompanyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: pagedCompanyId, name: "Pagination fixtures", issuePrefix: "PAG" }); + await db.insert(agents).values({ id: agentId, companyId: pagedCompanyId, name: "Writer", role: "engineer", adapterType: "codex_local" }); + await db.insert(heartbeatRuns).values({ id: runId, companyId: pagedCompanyId, agentId, status: "succeeded" }); + await db.insert(agentCommentary).values(Array.from({ length: 58 }, (_, index) => ({ + companyId: pagedCompanyId, agentId, runId, kind: "suggestion", body: `Message ${index}`, + idempotencyKey: String(index), payloadHash: "hash", + }))); + const first = await reader.list(pagedCompanyId, query(), sql`true`); + const second = await reader.list(pagedCompanyId, query({ offset: 50 }), sql`true`); + expect(first).toMatchObject({ total: 58, limit: 50, offset: 0 }); + expect(first.items).toHaveLength(50); + expect(second).toMatchObject({ total: 58, limit: 50, offset: 50 }); + expect(second.items).toHaveLength(8); + expect(new Set([...first.items, ...second.items].map((entry) => entry.id)).size).toBe(58); + }); + + it("combines filters and searches literal SQL wildcard characters", async () => { + expect((await reader.list(companyId, query({ agentId: firstAgentId, kind: "complaint" }), sql`true`)).total).toBe(0); + expect((await reader.list(companyId, query({ kind: "complaint", q: "100%" }), sql`true`)).total).toBe(1); + expect((await reader.list(companyId, query({ q: "literal_under_score" }), sql`true`)).total).toBe(1); + expect((await reader.list(companyId, query({ q: "nothing_here" }), sql`true`)).total).toBe(0); + }); + + it("applies source-task visibility to counts, previews and details and isolates companies", async () => { + const page = await reader.list(companyId, query(), eq(issues.id, visibleTaskId)); + expect(page.total).toBe(2); + expect(page.items.map((item) => item.id)).not.toContain(privateId); + expect(await reader.get(companyId, privateId, eq(issues.id, visibleTaskId))).toBeNull(); + expect(await reader.get(companyId, foreignId, sql`true`)).toBeNull(); + expect((await reader.list(companyId, query(), sql`true`)).items.map((item) => item.id)).not.toContain(foreignId); + }); + + it.each(["legacy", "native"])("keeps %s feedback hidden after its source task is deleted", async (transport) => { + const taskId = randomUUID(), runId = randomUUID(), id = randomUUID(); + await db.insert(issues).values({ id: taskId, companyId, title: "Deleted private task" }); + await db.insert(heartbeatRuns).values({ + id: runId, companyId, agentId: firstAgentId, status: "succeeded", + ...(transport === "native" ? { nativeIssueId: taskId } : { contextSnapshot: { issueId: taskId } }), + }); + await db.insert(agentCommentary).values({ + id, companyId, agentId: firstAgentId, runId, issueId: taskId, kind: "complaint", + body: "Deleted private task feedback", idempotencyKey: "deleted", payloadHash: "hash", + }); + await db.delete(issues).where(eq(issues.id, taskId)); + expect(await reader.get(companyId, id, eq(issues.id, visibleTaskId))).toBeNull(); + expect((await reader.list(companyId, query({ q: "Deleted private task feedback" }), eq(issues.id, visibleTaskId))).total).toBe(0); + }); + + it("enforces board membership, audit permission and filter bounds at the HTTP boundary", async () => { + let actor: Record = { type: "board", source: "local_implicit", userId: "operator" }; + const app = express(); + app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); }); + app.use("/api", agentCommentaryRoutes(db)); + app.use(errorHandler); + const url = `/api/companies/${companyId}/agent-commentary`; + expect((await request(app).get(url)).body.total).toBe(3); + expect((await request(app).get(`${url}/${firstId}`)).body.body).toBe(longBody); + expect((await request(app).get(`${url}/${foreignId}`)).status).toBe(404); + for (const filters of ["limit=101", "offset=-1", "kind=other", "agentId=bad-id"]) { + expect((await request(app).get(`${url}?${filters}`)).status).toBe(400); + } + actor = { type: "agent", companyId, agentId: firstAgentId }; + expect((await request(app).get(url)).status).toBe(403); + expect((await request(app).get(`${url}/${firstId}`)).status).toBe(403); + actor = { type: "board", source: "session", userId: "viewer", companyIds: [companyId] }; + expect((await request(app).get(url)).status).toBe(403); + actor = { type: "board", source: "session", userId: "operator", isInstanceAdmin: true, companyIds: [foreignCompanyId] }; + expect((await request(app).get(url)).status).toBe(403); + }); + it("applies actual task privacy to HTTP previews, counts, searches and details for audit-authorized board users", async () => { + vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce"); + const userId = randomUUID(), ownerId = randomUUID(); + await db.insert(authUsers).values([userId, ownerId].map((id) => ({ + id, name: "Board user", email: `${id}@example.test`, emailVerified: true, + createdAt: new Date(), updatedAt: new Date(), + }))); + await db.insert(companyMemberships).values({ + companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "viewer", + }); + await db.insert(principalPermissionGrants).values({ + companyId, principalType: "user", principalId: userId, permissionKey: "audit:view_agent_actions", + }); + await db.update(issues).set({ visibility: "private", privacyRootIssueId: privateTaskId, responsibleUserId: ownerId }).where(eq(issues.id, privateTaskId)); + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", source: "session", userId, companyIds: [companyId] }; + next(); + }); + app.use("/api", agentCommentaryRoutes(db)); + app.use(errorHandler); + const url = `/api/companies/${companyId}/agent-commentary`; + const deniedPage = await request(app).get(url).expect(200); + expect(deniedPage.body.total).toBe(2); + expect(deniedPage.body.items.map((entry: { id: string }) => entry.id)).not.toContain(privateId); + expect((await request(app).get(url).query({ q: "Private task feedback" }).expect(200)).body).toMatchObject({ total: 0, items: [] }); + await request(app).get(`${url}/${privateId}`).expect(404); + + const [grant] = await db.insert(issueAccessGrants).values({ + issueId: privateTaskId, subjectType: "user", subjectId: userId, source: "explicit", grantedByUserId: ownerId, + }).returning(); + const grantedPage = await request(app).get(url).expect(200); + expect(grantedPage.body.total).toBe(3); + expect(grantedPage.body.items.map((entry: { id: string }) => entry.id)).toContain(privateId); + expect((await request(app).get(`${url}/${privateId}`).expect(200)).body.body).toBe("Private task feedback"); + + await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id)); + expect((await request(app).get(url).expect(200)).body.total).toBe(2); + await request(app).get(`${url}/${privateId}`).expect(404); + }); + +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index f592b9fb062..82791597156 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -364,10 +364,19 @@ describe("openapi routes", () => { expect(operation.responses["404"]).toBeDefined(); }); - it("documents strict run-attributed feedback without a read endpoint", () => { + it("documents strict run-attributed feedback and board-only commentary reads", () => { const { spec } = loadSpecRoutes(); const path = spec.paths["/api/companies/{companyId}/agent-commentary"]; - expect(Object.keys(path)).toEqual(["post"]); + expect(Object.keys(path)).toEqual(["get", "post"]); + const detail = spec.paths["/api/companies/{companyId}/agent-commentary/{id}"].get; + for (const read of [path.get, detail]) { + expect(read.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(read.responses["403"]).toBeDefined(); + expect(read.responses["404"]).toBeDefined(); + } + expect(path.get.parameters.map((parameter: { name: string }) => parameter.name)).toEqual( + expect.arrayContaining(["agentId", "kind", "q", "limit", "offset"]), + ); const operation = path.post; expect(operation.security).toEqual([{ AgentBearerAuth: [] }]); expect(operation["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true }); diff --git a/server/src/routes/agent-commentary.ts b/server/src/routes/agent-commentary.ts index ae14bac9fe6..b3b71215e43 100644 --- a/server/src/routes/agent-commentary.ts +++ b/server/src/routes/agent-commentary.ts @@ -1,11 +1,45 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; -import { forbidden } from "../errors.js"; +import { agentCommentaryQuerySchema, isUuidLike } from "@paperclipai/shared"; +import { badRequest, forbidden, notFound } from "../errors.js"; import { submitAgentCommentary } from "../services/agent-commentary.js"; -import { assertCompanyAccess } from "./authz.js"; +import { agentCommentaryReader } from "../services/agent-commentary-reader.js"; +import { issueReadSqlCondition } from "../services/authorization.js"; +import { accessService } from "../services/access.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; export function agentCommentaryRoutes(db: Db) { const router = Router(); + const reader = agentCommentaryReader(db); + const access = accessService(db); + + async function authorizeRead(req: Request, companyId: string) { + assertBoard(req); + assertCompanyAccess(req, companyId); + if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin + && (!req.actor.userId || !await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions"))) { + throw forbidden("Missing permission: audit:view_agent_actions"); + } + return issueReadSqlCondition(db, req.actor); + } + + router.get("/companies/:companyId/agent-commentary", async (req, res) => { + const companyId = req.params.companyId as string; + const readCondition = await authorizeRead(req, companyId); + const query = agentCommentaryQuerySchema.safeParse(req.query); + if (!query.success) throw badRequest("Invalid agent commentary filters", query.error.issues); + res.json(await reader.list(companyId, query.data, readCondition)); + }); + + router.get("/companies/:companyId/agent-commentary/:id", async (req, res) => { + const companyId = req.params.companyId as string; + const readCondition = await authorizeRead(req, companyId); + const id = req.params.id as string; + if (!isUuidLike(id)) throw notFound("Commentary not found"); + const entry = await reader.get(companyId, id, readCondition); + if (!entry) throw notFound("Commentary not found"); + res.json(entry); + }); router.post("/companies/:companyId/agent-commentary", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index c861eeba8e5..2496ec184a9 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -31,6 +31,7 @@ import { slackSearchConfigSchema, // Agent submitAgentCommentarySchema, + agentCommentaryQuerySchema, AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, @@ -1757,6 +1758,7 @@ function resolveOperationAuthLevel( || /^\/api\/companies\/\{companyId\}\/agents\/\{agentId\}\/grok-binding(?:\/(?:routine|verify|retry|stop|recover))?$/.test(path)) return "board"; if (PUBLIC_OPERATIONS.has(key)) return "public"; if (key === "POST /api/companies/{companyId}/agent-commentary") return "agent_heartbeat"; + if (method.toUpperCase() === "GET" && path.startsWith("/api/companies/{companyId}/agent-commentary")) return "board"; if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run"; if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools"; if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin"; @@ -5686,6 +5688,26 @@ registry.registerPath({ // ─── Agent feedback ────────────────────────────────────────────────────────── +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/agent-commentary", + tags: ["agents"], + summary: "Browse agent suggestions and complaints", + description: "Board-only. Requires company membership, audit:view_agent_actions. Source-task privacy filters both previews and counts. Returns items, total, limit and offset; previews contain at most 280 characters and omit full bodies.", + request: { params: z.object({ companyId: z.string() }), query: agentCommentaryQuerySchema }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/agent-commentary/{id}", + tags: ["agents"], + summary: "Read a full agent commentary message", + description: "Same board, permission and task-privacy gates as the list. Returns the full body with agent, task and run context. Foreign-company and invisible entries return 404.", + request: { params: z.object({ companyId: z.string(), id: z.string().uuid() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + const agentCommentaryAcknowledgementSchema = z.object({ id: z.string().uuid(), kind: z.enum(["complaint", "suggestion"]), diff --git a/server/src/services/agent-commentary-reader.ts b/server/src/services/agent-commentary-reader.ts new file mode 100644 index 00000000000..19abd45c30d --- /dev/null +++ b/server/src/services/agent-commentary-reader.ts @@ -0,0 +1,70 @@ +import { and, count, desc, eq, ilike, isNull, or, sql, type SQL } from "drizzle-orm"; +import { agentCommentary, agents, heartbeatRuns, issues, type Db } from "@paperclipai/db"; +import type { AgentCommentaryDetail, AgentCommentaryEntry, AgentCommentaryPage, AgentCommentaryQuery } from "@paperclipai/shared"; + +const entryFields = { + id: agentCommentary.id, + kind: agentCommentary.kind, + preview: sql`left(${agentCommentary.body}, 280)`, + createdAt: agentCommentary.createdAt, + agent: { id: agents.id, name: agents.name, icon: agents.icon }, + runId: agentCommentary.runId, + task: { id: issues.id, identifier: issues.identifier, title: issues.title }, +}; + +export function agentCommentaryReader(db: Db) { + function visible(companyId: string, readCondition: SQL) { + return and( + eq(agentCommentary.companyId, companyId), + or( + and(isNull(agentCommentary.issueId), sql`coalesce( + ${heartbeatRuns.nativeIssueId}::text, + ${heartbeatRuns.contextSnapshot}->>'issueId', + ${heartbeatRuns.contextSnapshot}->>'taskId' + ) is null`), + and(sql`${issues.id} is not null`, readCondition), + ), + ); + } + + function entries() { + return db.select(entryFields).from(agentCommentary) + .innerJoin(agents, and(eq(agents.id, agentCommentary.agentId), eq(agents.companyId, agentCommentary.companyId))) + .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, agentCommentary.runId), eq(heartbeatRuns.companyId, agentCommentary.companyId))) + .leftJoin(issues, and(eq(issues.id, agentCommentary.issueId), eq(issues.companyId, agentCommentary.companyId))); + } + + return { + async list(companyId: string, query: AgentCommentaryQuery, readCondition: SQL): Promise { + const where = and( + visible(companyId, readCondition), + query.agentId ? eq(agentCommentary.agentId, query.agentId) : undefined, + query.kind ? eq(agentCommentary.kind, query.kind) : undefined, + query.q ? ilike(agentCommentary.body, `%${query.q.replace(/[\\%_]/g, "\\$&")}%`) : undefined, + ); + const [rows, totals] = await Promise.all([ + entries().where(where).orderBy(desc(agentCommentary.createdAt), desc(agentCommentary.id)).limit(query.limit).offset(query.offset), + db.select({ total: count() }).from(agentCommentary) + .innerJoin(agents, and(eq(agents.id, agentCommentary.agentId), eq(agents.companyId, agentCommentary.companyId))) + .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, agentCommentary.runId), eq(heartbeatRuns.companyId, agentCommentary.companyId))) + .leftJoin(issues, and(eq(issues.id, agentCommentary.issueId), eq(issues.companyId, agentCommentary.companyId))) + .where(where), + ]); + return { + items: rows.map((row): AgentCommentaryEntry => ({ ...row, createdAt: row.createdAt.toISOString() })), + total: totals[0]?.total ?? 0, + limit: query.limit, + offset: query.offset, + }; + }, + + async get(companyId: string, id: string, readCondition: SQL): Promise { + const [row] = await db.select({ ...entryFields, body: agentCommentary.body }).from(agentCommentary) + .innerJoin(agents, and(eq(agents.id, agentCommentary.agentId), eq(agents.companyId, agentCommentary.companyId))) + .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.id, agentCommentary.runId), eq(heartbeatRuns.companyId, agentCommentary.companyId))) + .leftJoin(issues, and(eq(issues.id, agentCommentary.issueId), eq(issues.companyId, agentCommentary.companyId))) + .where(and(visible(companyId, readCondition), eq(agentCommentary.id, id))).limit(1); + return row ? { ...row, createdAt: row.createdAt.toISOString() } : null; + }, + }; +} diff --git a/ui/src/App.activity-routing.test.tsx b/ui/src/App.activity-routing.test.tsx index 6d97e0d1dc7..f4afd3debec 100644 --- a/ui/src/App.activity-routing.test.tsx +++ b/ui/src/App.activity-routing.test.tsx @@ -278,12 +278,15 @@ describe("App Activity routing (PAP-16302)", () => { flushSync(() => costsRoot.unmount()); }); - it("omits streamlined-only Audit routes when Streamlined UI is disabled", async () => { - streamlinedUiState.enabled = false; - const root = renderAppAt(container, "/PAP/activity/runs"); - await waitForRoute(container, "PRODUCTION_PLUGIN_FALLBACK"); - expect(container.textContent).not.toContain("AUDIT_RUNS"); - flushSync(() => root.unmount()); + it.each([true, false])("serves all Audit tab destinations with Streamlined UI set to %s", async (enabled) => { + streamlinedUiState.enabled = enabled; + for (const section of ["runs", "costs", "budgets", "timeline", "commentary"]) { + const path = `/PAP/activity/${section}?agentId=agent-1`; + const root = renderAppAt(container, path); + await waitForRoute(container, `AUDIT_${section.toUpperCase()}@${path}`); + expect(container.textContent).not.toContain("NOT_FOUND"); + flushSync(() => root.unmount()); + } }); it("does not mount streamlined routes before the experiment setting loads", async () => { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f7eaabe95da..351bc10e276 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -368,12 +368,13 @@ function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: b } /> } /> : } /> + } /> + } /> + } /> + } /> + } /> {streamlinedUiEnabled ? ( <> - } /> - } /> - } /> - } /> } /> } /> } /> diff --git a/ui/src/api/agentCommentary.ts b/ui/src/api/agentCommentary.ts new file mode 100644 index 00000000000..847696c61e1 --- /dev/null +++ b/ui/src/api/agentCommentary.ts @@ -0,0 +1,14 @@ +import type { AgentCommentaryDetail, AgentCommentaryPage, AgentCommentaryQuery } from "@paperclipai/shared"; +import { api } from "./client"; + +export const agentCommentaryApi = { + list(companyId: string, filters: Partial, signal?: AbortSignal) { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== "") query.set(key, String(value)); + } + return api.get(`/companies/${companyId}/agent-commentary?${query}`, { signal }); + }, + get: (companyId: string, id: string, signal?: AbortSignal) => + api.get(`/companies/${companyId}/agent-commentary/${encodeURIComponent(id)}`, { signal }), +}; diff --git a/ui/src/components/Sidebar.production.tsx b/ui/src/components/Sidebar.production.tsx index 08f92529337..7a2b2a14d7b 100644 --- a/ui/src/components/Sidebar.production.tsx +++ b/ui/src/components/Sidebar.production.tsx @@ -233,6 +233,7 @@ export function Sidebar() { {/* One entry — /audit merged into the rich Activity feed (PAP-16302). */} + diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index f9a6c6e5f2c..923d110e929 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -294,6 +294,7 @@ export function Sidebar({ children }: { children?: ReactNode }) { + diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 83d8ed828fe..22d3f83d654 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -1,4 +1,8 @@ export const queryKeys = { + agentCommentary: { + list: (companyId: string, filters: object) => ["agent-commentary", companyId, "list", filters] as const, + detail: (companyId: string, id: string | null) => ["agent-commentary", companyId, "detail", id] as const, + }, skillSources: { projects: (companyId: string) => ['project-skill-repositories', companyId] as const, preview: (companyId: string, repositoryUrl: string, connectionId: string | null, commitSha: string | null, skillPath: string, filePath: string) => ['skill-sources', companyId, 'preview', repositoryUrl, connectionId, commitSha, skillPath, filePath] as const, diff --git a/ui/src/pages/audit/AgentCommentary.test.tsx b/ui/src/pages/audit/AgentCommentary.test.tsx new file mode 100644 index 00000000000..80d05f845c0 --- /dev/null +++ b/ui/src/pages/audit/AgentCommentary.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeProvider } from "@/context/ThemeContext"; +import { AgentCommentary } from "./AgentCommentary"; + +const mocks = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn() })); +vi.mock("@/api/agentCommentary", () => ({ agentCommentaryApi: mocks })); +vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } })); +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompany: { issuePrefix: "TES" } }), + useOptionalCompany: () => null, +})); + +const entry = { + id: "20000000-0000-4000-8000-000000000001", + agent: { id: "10000000-0000-4000-8000-000000000001", name: "Writer", icon: null }, + runId: "30000000-0000-4000-8000-000000000001", + kind: "suggestion", preview: "Preserve context", body: "Preserve context across retries.", + createdAt: "2026-10-19T12:00:00Z", task: null, +}; + +describe("Commentary links and pagination", () => { + let container: HTMLDivElement; + let root: ReturnType; + let client: QueryClient; + + beforeEach(() => { + vi.clearAllMocks(); + window.history.replaceState(null, "", "/TES/activity/commentary"); + mocks.list.mockImplementation(async (_companyId, filters) => ({ + items: [entry], total: 51, limit: 50, offset: filters.offset, + })); + mocks.get.mockResolvedValue(entry); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(() => { + flushSync(() => root.unmount()); + client.clear(); + container.remove(); + }); + function render() { + flushSync(() => root.render( + + + , + )); + } + + it("loads pages of 50 and preserves the page in a shareable message URL", async () => { + render(); + await vi.waitFor(() => expect(container.querySelector('[aria-label="Read suggestion from Writer"]')).not.toBeNull()); + expect(mocks.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ limit: 50, offset: 0 }), expect.anything()); + flushSync(() => container.querySelector('[aria-label="Next page"]')!.click()); + await vi.waitFor(() => expect(mocks.list).toHaveBeenLastCalledWith("company-1", expect.objectContaining({ limit: 50, offset: 50 }), expect.anything())); + await vi.waitFor(() => expect(container.querySelector('[aria-label="Read suggestion from Writer"]')).not.toBeNull()); + flushSync(() => container.querySelector('[aria-label="Read suggestion from Writer"]')!.click()); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')?.textContent).toContain(entry.body)); + expect(new URLSearchParams(window.location.search).get("entry")).toBe(entry.id); + expect(new URLSearchParams(window.location.search).get("offset")).toBe("50"); + flushSync(() => document.querySelector('[role="dialog"] button:last-child')!.click()); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')).toBeNull()); + expect(new URLSearchParams(window.location.search).has("entry")).toBe(false); + expect(new URLSearchParams(window.location.search).get("offset")).toBe("50"); + }); + + it("opens the full message directly from a copied URL", async () => { + window.history.replaceState(null, "", `/TES/activity/commentary?kind=suggestion&entry=${entry.id}`); + render(); + await vi.waitFor(() => expect(document.querySelector('[role="dialog"]')?.textContent).toContain(entry.body)); + expect(mocks.get).toHaveBeenCalledWith("company-1", entry.id, expect.anything()); + expect(mocks.list).toHaveBeenCalledWith("company-1", expect.objectContaining({ kind: "suggestion", limit: 50 }), expect.anything()); + }); +}); diff --git a/ui/src/pages/audit/AgentCommentary.tsx b/ui/src/pages/audit/AgentCommentary.tsx new file mode 100644 index 00000000000..bb939845257 --- /dev/null +++ b/ui/src/pages/audit/AgentCommentary.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { AgentCommentaryEntry, AgentCommentaryKind } from "@paperclipai/shared"; +import { ArrowUpRight, ChevronLeft, ChevronRight, Lightbulb, MessageSquareWarning, Search } from "lucide-react"; +import { agentCommentaryApi } from "@/api/agentCommentary"; +import { agentsApi } from "@/api/agents"; +import { AgentIdentity } from "@/components/AgentIdentity"; +import { EmptyState } from "@/components/EmptyState"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { queryKeys } from "@/lib/queryKeys"; +import { Link, useSearchParams } from "@/lib/router"; +import { relativeTime } from "@/lib/utils"; + +const PAGE_SIZE = 50; +const ALL = "all"; + +function MessageKind({ kind }: { kind: AgentCommentaryKind }) { + const Icon = kind === "suggestion" ? Lightbulb : MessageSquareWarning; + return {kind === "suggestion" ? "Suggestion" : "Complaint"}; +} + +function CommentaryRow({ entry, onOpen }: { entry: AgentCommentaryEntry; onOpen: (trigger: HTMLButtonElement) => void }) { + return ( +
  • + +
  • + ); +} + +function CommentaryDetail({ companyId, id, onClose, triggerRef }: { companyId: string; id: string | null; onClose: () => void; triggerRef: RefObject }) { + const detail = useQuery({ + queryKey: queryKeys.agentCommentary.detail(companyId, id), + queryFn: ({ signal }) => agentCommentaryApi.get(companyId, id!, signal), + enabled: id !== null, + }); + const entry = detail.data; + return ( + { if (!open) onClose(); }}> + { + if (triggerRef.current?.isConnected) { + event.preventDefault(); + triggerRef.current.focus(); + } + }}> + + {entry?.kind === "complaint" ? "Complaint" : entry ? "Suggestion" : "Commentary"} + {entry ? `Submitted ${new Date(entry.createdAt).toLocaleString()}` : "Message details and source context."} + + {detail.isPending ?

    Loading message…

    : detail.error ? ( +

    {detail.error.message}

    + ) : entry ? <> +
    +
    {entry.body}
    +
    + {entry.task && {entry.task.identifier ?? "Source task"}} + View run +
    + : null} +
    +
    + ); +} + +export function AgentCommentary({ companyId }: { companyId: string }) { + const [params, setParams] = useSearchParams(); + const q = params.get("q") ?? ""; + const agentId = params.get("agentId") ?? ALL; + const kind = params.get("kind") === "suggestion" || params.get("kind") === "complaint" ? params.get("kind") as AgentCommentaryKind : undefined; + const offset = Math.min(100_000, Math.max(0, Math.floor(Number(params.get("offset")) || 0))); + const selectedId = params.get("entry") || null; + const [search, setSearch] = useState(q); + const triggerRef = useRef(null); + const updateFilters = useCallback((patch: Record) => { + setParams((current) => { + const next = new URLSearchParams(current); + next.delete("offset"); + next.delete("entry"); + for (const [key, value] of Object.entries(patch)) { + if (!value || value === ALL) next.delete(key); else next.set(key, value); + } + return next; + }, { replace: true }); + }, [setParams]); + useEffect(() => setSearch(q), [q]); + useEffect(() => { + if (search === q) return; + const timer = window.setTimeout(() => updateFilters({ q: search }), 250); + return () => window.clearTimeout(timer); + }, [search, q, updateFilters]); + + function selectEntry(id: string | null) { + setParams((current) => { + const next = new URLSearchParams(current); + if (id) next.set("entry", id); else next.delete("entry"); + return next; + }, { replace: id === null }); + } + const filters = { q: q || undefined, agentId: agentId === ALL ? undefined : agentId, kind, offset, limit: PAGE_SIZE }; + const messages = useQuery({ queryKey: queryKeys.agentCommentary.list(companyId, filters), queryFn: ({ signal }) => agentCommentaryApi.list(companyId, filters, signal) }); + const agents = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId) }); + const hasFilters = Boolean(q || agentId !== ALL || kind); + const clearFilters = () => updateFilters({ q: "", agentId: "", kind: "" }); + + return ( +
    +

    Commentary

    Suggestions and complaints from your agents.

    +
    +
    setSearch(event.target.value)} className="pl-9" />
    + + + {hasFilters && } +
    + {agents.error &&

    Could not load agent filters.

    } + {messages.isPending ?

    Loading commentary…

    : messages.error ? ( +

    {messages.error.message}

    + ) : messages.data?.items.length === 0 ? ( + 0 ? "No messages on this page." : hasFilters ? "No messages match these filters." : "No agent commentary yet."} description={offset > 0 ? "Return to the newest messages." : hasFilters ? "Try another agent, message type, or search." : "Suggestions and complaints will appear here when agents submit them."} action={offset > 0 ? "Back to latest" : hasFilters ? "Clear filters" : undefined} onAction={offset > 0 ? () => updateFilters({ offset: "" }) : hasFilters ? clearFilters : undefined} hideActionIcon /> + ) : messages.data ? <> +
    + +
      {messages.data.items.map((entry) => { triggerRef.current = trigger; selectEntry(entry.id); }} />)}
    +
    +
    +

    {offset + 1}–{offset + messages.data.items.length} of {messages.data.total} messages

    +
    +
    + : null} + selectEntry(null)} triggerRef={triggerRef} /> +
    + ); +} diff --git a/ui/src/pages/audit/AuditHub.test.tsx b/ui/src/pages/audit/AuditHub.test.tsx index 74634a87c9a..5446854c2e3 100644 --- a/ui/src/pages/audit/AuditHub.test.tsx +++ b/ui/src/pages/audit/AuditHub.test.tsx @@ -40,6 +40,12 @@ vi.mock("./AuditFeed", () => ({ ), })); +vi.mock("./AgentCommentary", () => ({ + AgentCommentary: ({ companyId }: { companyId: string }) => ( +
    + ), +})); + vi.mock("./AuditRuns", () => ({ AuditRuns: ({ companyId, routineId }: { companyId: string; routineId?: string }) => (
    @@ -88,7 +94,7 @@ describe("AuditHub", () => { vi.clearAllMocks(); }); - function render(section: "activity" | "runs" | "costs" | "budgets" | "timeline") { + function render(section: "activity" | "runs" | "costs" | "budgets" | "timeline" | "commentary") { root = createRoot(container); flushSync(() => root.render()); } @@ -97,9 +103,10 @@ describe("AuditHub", () => { currentSearch = "mode=agents&agentId=agent-1&runId=run-1&action=tool_"; render("activity"); - expect(container.querySelectorAll('[role="tab"]')).toHaveLength(5); + expect(container.querySelectorAll('[role="tab"]')).toHaveLength(6); expect(container.textContent).toContain("Activity"); expect(container.textContent).toContain("Runs"); + expect(container.textContent).toContain("Commentary"); expect(container.textContent).toContain("Costs"); expect(container.textContent).toContain("Budgets"); expect(container.textContent).toContain("Timeline"); @@ -112,6 +119,17 @@ describe("AuditHub", () => { expect(setBreadcrumbsMock).toHaveBeenCalledWith([{ label: "Audit" }]); }); + it("renders Commentary for the selected company without an experimental gate", () => { + render("commentary"); + expect(container.querySelector('[data-testid="agent-commentary"]')?.dataset.company).toBe("company-1"); + const tab = Array.from(container.querySelectorAll('[role="tab"]')) + .find((item) => item.textContent?.trim() === "Commentary"); + expect(tab?.getAttribute("aria-selected")).toBe("true"); + expect(setBreadcrumbsMock).toHaveBeenCalledWith([ + { label: "Audit", href: "/activity" }, { label: "Commentary" }, + ]); + }); + it("uses routine-scoped activity instead of the privileged organization feed", () => { currentSearch = "entityType=routine&entityId=routine-1"; render("activity"); @@ -136,7 +154,7 @@ describe("AuditHub", () => { const labels = Array.from(container.querySelectorAll('[role="tab"]')) .map((tab) => tab.textContent?.trim()); - expect(labels).toEqual(["Activity", "Runs", "Costs", "Budgets", "Timeline"]); + expect(labels).toEqual(["Activity", "Runs", "Commentary", "Costs", "Budgets", "Timeline"]); expect(container.querySelector('[data-testid="audit-timeline"]')?.dataset.embedded) .toBe("true"); }); diff --git a/ui/src/pages/audit/AuditHub.tsx b/ui/src/pages/audit/AuditHub.tsx index 9369ad8c54c..5d9173a9dc3 100644 --- a/ui/src/pages/audit/AuditHub.tsx +++ b/ui/src/pages/audit/AuditHub.tsx @@ -10,6 +10,7 @@ import { Costs } from "@/pages/Costs"; import { Timeline } from "@/pages/Timeline"; import { AuditFeed, type AuditFeedMode } from "./AuditFeed"; import { AuditRuns } from "./AuditRuns"; +import { AgentCommentary } from "./AgentCommentary"; import { RoutineAuditActivity } from "./RoutineAuditActivity"; import { AUDIT_SECTIONS, @@ -97,7 +98,9 @@ export function AuditHub({ section }: { section: AuditSection }) { - {section === "activity" && routineId ? ( + {section === "commentary" ? ( + + ) : section === "activity" && routineId ? ( ) : section === "activity" ? ( { it("builds stable section routes", () => { expect(auditSectionHref("activity")).toBe("/activity"); + expect(auditSectionHref("commentary")).toBe("/activity/commentary"); expect(auditSectionHref("runs")).toBe("/activity/runs"); expect(auditSectionHref("costs")).toBe("/activity/costs"); expect(auditSectionHref("budgets")).toBe("/activity/budgets"); diff --git a/ui/src/pages/audit/audit-navigation.ts b/ui/src/pages/audit/audit-navigation.ts index 8562295ea53..e215a8d4ae3 100644 --- a/ui/src/pages/audit/audit-navigation.ts +++ b/ui/src/pages/audit/audit-navigation.ts @@ -1,4 +1,4 @@ -export type AuditSection = "activity" | "runs" | "costs" | "budgets" | "timeline"; +export type AuditSection = "activity" | "runs" | "costs" | "budgets" | "timeline" | "commentary"; export const AUDIT_SECTIONS: ReadonlyArray<{ value: AuditSection; @@ -7,6 +7,7 @@ export const AUDIT_SECTIONS: ReadonlyArray<{ }> = [ { value: "activity", label: "Activity", href: "/activity" }, { value: "runs", label: "Runs", href: "/activity/runs" }, + { value: "commentary", label: "Commentary", href: "/activity/commentary" }, { value: "costs", label: "Costs", href: "/activity/costs" }, { value: "budgets", label: "Budgets", href: "/activity/budgets" }, { value: "timeline", label: "Timeline", href: "/activity/timeline" }, diff --git a/ui/storybook/stories/activity/agent-commentary.stories.tsx b/ui/storybook/stories/activity/agent-commentary.stories.tsx new file mode 100644 index 00000000000..dca3ac76982 --- /dev/null +++ b/ui/storybook/stories/activity/agent-commentary.stories.tsx @@ -0,0 +1,83 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { Routes, Route } from "@/lib/router"; +import { Layout } from "@/components/Layout"; +import { AuditHub } from "@/pages/audit/AuditHub"; +import { PluginLauncherProvider } from "@/plugins/launchers"; +import { commentaryEntries, prepareCommentaryFixtures } from "../../support/agent-commentary/fixtures"; + +function CommentaryJourney() { + return }>} />} />; +} + +const meta = { + id: "agent-commentary", + title: "Oversight and operation/Dashboards and audit trails/Commentary", + component: CommentaryJourney, + parameters: { + layout: "fullscreen", + initialEntries: ["/PAP/activity/commentary"], + paperclip: { + capabilities: ["activity/agent-commentary"], + status: "current", + implementation: "production", + boundary: "fixtures", + notes: "Production Audit shell, commentary page, filters and modal. Synthetic feedback; API filtering, pagination and details are simulated. Server authorization is verified separately.", + }, + }, + beforeEach: ({ parameters }) => prepareCommentaryFixtures(parameters.commentary ?? {}).cleanup, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Messages: Story = {}; +export const ReadMessage: Story = { + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + const existingDialog = body.queryByRole("dialog"); + if (existingDialog) await userEvent.click(within(existingDialog).getByRole("button", { name: "Close" })); + const rows = await within(canvasElement).findAllByRole("button", { name: "Read suggestion from Amy (Opus)" }); + await userEvent.click(rows[0]); + const dialog = await within(canvasElement.ownerDocument.body).findByRole("dialog"); + await expect(await within(dialog).findByText("What would help")).toBeVisible(); + await expect(within(dialog).getByRole("link", { name: "View run" }).getAttribute("href")).toContain("/runs/"); + }, +}; +export const FilterByAgentAndType: Story = { + parameters: { initialEntries: ["/PAP/activity/commentary?agentId=10000000-0000-4000-8000-000000000001&kind=complaint"] }, + play: async ({ canvasElement }) => { await expect(await within(canvasElement).findByText("1–1 of 1 messages")).toBeVisible(); }, +}; +export const SearchMessages: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.type(await canvas.findByRole("textbox", { name: "Search commentary" }), "evidence"); + await userEvent.click(canvas.getByRole("combobox", { name: "Filter by agent" })); + await userEvent.click(await within(canvasElement.ownerDocument.body).findByRole("option", { name: "Amy (Opus)" })); + await expect(await canvas.findByText("1–1 of 1 messages")).toBeVisible(); + await expect(canvas.getByRole("combobox", { name: "Filter by agent" })).toHaveTextContent("Amy (Opus)"); + }, +}; +export const LinkedMessage: Story = { + parameters: { initialEntries: [`/PAP/activity/commentary?entry=${commentaryEntries[0].id}`] }, + play: async ({ canvasElement }) => { + const dialog = await within(canvasElement.ownerDocument.body).findByRole("dialog"); + await expect(await within(dialog).findByText("What would help")).toBeVisible(); + await userEvent.click(within(dialog).getByRole("button", { name: "Close" })); + await expect(within(canvasElement).getByRole("list", { name: "Commentary messages" })).toBeVisible(); + }, +}; +export const LongMessage: Story = { parameters: { commentary: { longMessage: true }, initialEntries: [`/PAP/activity/commentary?entry=${commentaryEntries[0].id}`] } }; +export const Pagination: Story = { parameters: { commentary: { paginated: true } }, play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByText("1–50 of 58 messages")).toBeVisible(); + await expect(within(canvas.getByRole("list", { name: "Commentary messages" })).getAllByRole("listitem")).toHaveLength(50); + await userEvent.click(await canvas.findByRole("button", { name: "Next page" })); + await expect(await canvas.findByText("51–58 of 58 messages")).toBeVisible(); +} }; +export const Empty: Story = { parameters: { commentary: { state: "empty" } } }; +export const NoMatches: Story = { parameters: { initialEntries: ["/PAP/activity/commentary?q=no-matching-message"] } }; +export const Loading: Story = { parameters: { commentary: { state: "loading" } } }; +export const Failed: Story = { parameters: { commentary: { state: "failure" } } }; +export const PermissionDenied: Story = { parameters: { commentary: { state: "permission" } } }; +export const Mobile: Story = { globals: { viewport: { value: "mobile1", isRotated: false } }, parameters: { waitForViewport: true } }; +export const Light: Story = { globals: { theme: "light" } }; diff --git a/ui/storybook/support/agent-commentary/fixtures.ts b/ui/storybook/support/agent-commentary/fixtures.ts new file mode 100644 index 00000000000..e789e5d9add --- /dev/null +++ b/ui/storybook/support/agent-commentary/fixtures.ts @@ -0,0 +1,47 @@ +import { type AgentCommentaryDetail } from "@paperclipai/shared"; +import { storybookAgents } from "../../fixtures/paperclipData"; +import { prepareFixtureApi, type FixtureState } from "../capability-gaps/api"; + +export const commentaryAgents = ["Amy (Opus)", "Codie", "VoC", "Fry (Codex)"].map((name, index) => ({ + ...storybookAgents[index % storybookAgents.length], + id: `10000000-0000-4000-8000-00000000000${index + 1}`, + name, +})); + +const messages = [ + { agent: 0, kind: "suggestion", task: "Keep remote tools available after transient failures", body: "One failed Sentry request removes every tool on the connection for the rest of the run. A tool that worked moments earlier returns `tool_not_found`, even though the connection is still authorized.\n\n### What would help\n\nTreat upstream fetch failures as transient. Keep the tool catalog available, retry with a bounded backoff, and request reconnection only after a confirmed authentication error.\n\n### Impact\n\nThe fix was ready, but I could not verify recovery or resolve the incident. Another scheduled wake repeated the same setup work." }, + { agent: 1, kind: "suggestion", task: "Make monitor validation errors actionable", body: "The monitor tool returned `invalid_tool_call` without naming the rejected field. Please include the validation path and accepted limit so an agent can correct the call without guessing.\n\nA shorter notes field succeeded, but the tool schema did not expose that limit." }, + { agent: 0, kind: "complaint", task: "Run targeted tests in the managed sandbox", body: "I found the bug in ten minutes and then spent the rest of the run fighting the installer. The sandbox ran out of memory twice before I could run one targeted test.\n\nA fresh workspace needs a dependency cache and enough memory to run the checks the repository requires." }, + { agent: 2, kind: "suggestion", task: "Preserve a usable checkout across resumed runs", body: "The resumed workspace contains the source files but no Git metadata or installed dependencies. Preserve the branch, commit, and dependency cache so a small review fix does not require reconstructing the entire checkout." }, + { agent: 3, kind: "complaint", task: "Verify the managed GitHub launcher at startup", body: "The connection says ready, but the CLI still asks me to log in. The working launcher is in a directory that is not on PATH. Every doorway opens onto another hallway." }, + { agent: 0, kind: "suggestion", task: "Append new evidence without replacing the case history", body: "Updating a case with one new evidence entry replaces the whole evidence array. The workflow describes an accumulating history, so this is easy to miss.\n\nAdd an explicit append operation and reserve replacement for intentional edits. That would preserve earlier diagnosis and verification evidence." }, +] as const; + +export const commentaryEntries: AgentCommentaryDetail[] = messages.map((message, index) => ({ + id: `20000000-0000-4000-8000-00000000000${index + 1}`, + kind: message.kind, + preview: message.body.slice(0, 280), + body: message.body, + createdAt: new Date(Date.UTC(2026, 9, 19, 17, 26 - index * 12)).toISOString(), + agent: commentaryAgents[message.agent], + runId: `30000000-0000-4000-8000-00000000000${index + 1}`, + task: { id: `40000000-0000-4000-8000-00000000000${index + 1}`, identifier: `PAP-${241 + index}`, title: message.task }, +})); + +export function prepareCommentaryFixtures(options: { state?: FixtureState; paginated?: boolean; longMessage?: boolean } = {}) { + const entries = options.paginated ? Array.from({ length: 58 }, (_, index) => ({ ...commentaryEntries[index % commentaryEntries.length], id: `20000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}` })) : commentaryEntries.map((entry) => ({ ...entry })); + if (options.longMessage) entries[0].body += "\n\n" + Array.from({ length: 18 }, (_, index) => `### Observation ${index + 1}\n\nThe run retained its task and branch references, but the tool catalog did not recover. A useful error should explain which operation failed, whether it is safe to retry, and which context will survive the retry.`).join("\n\n"); + return prepareFixtureApi([ + { path: "/api/companies/company-storybook/agents", read: () => commentaryAgents }, + { path: "/api/companies/company-storybook/agent-commentary", state: options.state, read: ({ url }) => { + const agent = url.searchParams.get("agentId"); + const kind = url.searchParams.get("kind"); + const query = url.searchParams.get("q")?.toLowerCase(); + const offset = Number(url.searchParams.get("offset") ?? 0); + const limit = Number(url.searchParams.get("limit") ?? 50); + const matches = options.state === "empty" ? [] : entries.filter((entry) => (!agent || entry.agent.id === agent) && (!kind || entry.kind === kind) && (!query || entry.body.toLowerCase().includes(query))); + return { items: matches.slice(offset, offset + limit).map(({ body: _body, ...entry }) => entry), total: matches.length, offset, limit }; + } }, + { path: /^\/api\/companies\/company-storybook\/agent-commentary\//, read: ({ url }) => entries.find((entry) => entry.id === url.pathname.split("/").at(-1)) ?? Response.json({ error: "Commentary not found" }, { status: 404 }) }, + ]); +}