feat: add Commentary to Audit with filters and shareable messages (#16884)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents can submit suggestions and complaints while they work.
> - Operators currently need database access to read those messages.
> - That makes it hard to find feedback from one agent or share a
specific message.
> - This pull request adds a standard Commentary tab in Audit.
> - Operators can filter messages, browse 50 at a time, and share links
to full message dialogs.

## Linked Issues or Issue Description

Refs #15367, which added feedback storage and agent submission. The
duplicate search found no open PR for a commentary reader.

**Subsystem affected**

Server, shared API contracts, and the board UI.

**Problem or motivation**

Agent suggestions and complaints are stored but have no board reader.
Operators must query the database to inspect feedback.

**Proposed solution**

Add Commentary to Audit without an experimental toggle. Show previews,
agent and message-type filters, text search, and 50-message pages.
Opening a row updates the URL. A copied URL opens the full message with
its agent, task, and run context.

**Alternatives considered**

Direct database queries remain useful for operators, but do not provide
a normal product workflow. An experimental setting would add setup to a
read-only audit page.

**Roadmap alignment**

This completes the operator view for the existing feedback feature. It
supports human oversight and does not change agent execution or task
ownership.

## What Changed

- Add company-scoped, board-only list and detail endpoints with audit
permission and source-task privacy checks.
- Return bounded previews and counts. Fetch full bodies only when a
message opens.
- Add the Commentary tab, search, filters, 50-row pagination, and
URL-based selection. Audit tab destinations work in both layouts, with a
classic sidebar entry for Commentary.
- Add production Storybook examples for populated, filtered, linked,
long, paginated, empty, loading, failed, denied, mobile, and light
states.
- Update API documentation and the feature and agent access maps.

## Verification

- Final head `abc4a251c14cfbfec007542214beec528f2b18bf`: 55 checks pass;
two conditional Storybook checks are skipped. The PR workflow is
successful. Greptile is 5/5 with all findings resolved.
- Focused database, HTTP, submission, OpenAPI, and UI regression tests
cover company isolation, task privacy, filter bounds, 50-row pagination,
and copied message links.
- Browser Storybook journeys cover row opening, direct links, combined
filters, search, and pagination. Storybook uses synthetic API fixtures;
database authorization is tested separately.
- Token gates, feature maps, and Storybook catalog validation pass.
- `pnpm -r typecheck`, `pnpm build`, and `pnpm build-storybook` pass on
the rebased branch. All 80 focused tests and all 37 Audit tests pass,
including the real App route table in both layouts, task
grant/revocation through HTTP, deleted-task privacy, and URL UI
regressions. The full suite passes in the parallel PR CI matrix. The
local serial full-suite run was stopped before completion after checking
the recorded 94-minute serial duration. Local focused tests, workspace
typecheck, production build, and final server/UI checks pass.
- The linked-message Storybook test initially asserted against the
loading dialog. It now waits for the message body; both direct-link and
row-open journeys pass.
- Native and legacy deleted-task privacy regressions failed before the
fix and pass afterward. The reader checks the run task binding after the
task foreign key clears.
- A rapid search-then-agent-filter browser regression reproduced the
search overwriting the selected agent. The pending search now restarts
when URL filters change. The same browser test passes with both filters
applied.
- CI found two existing AuditHub assertions that still expected five
tabs. They now assert the six-tab model and selected-company Commentary
rendering. All 37 Audit tests pass. All other test and build jobs passed
before this test-only correction.

- The [agent-chat attachment browser
test](https://github.com/paperclipai/paperclip/actions/runs/39307881171/job/118250189664)
timed out waiting for an attachment preview. The [same shard passed on
the prior
head](https://github.com/paperclipai/paperclip/actions/runs/39306658703/job/118245498343)
with identical application code; the intervening change only updates
AuditHub unit tests. The single retry passed on the same head. The
precise cause of the earlier failure remains unconfirmed.

## Risks

- Commentary may contain sensitive task context. The reader requires
board access and audit permission, and filters source-task visibility
before returning previews, counts, or full bodies.
- Text search scans stored message bodies. Pages are bounded and full
bodies are fetched on demand.
- Offset pagination can shift if new messages arrive while paging.
Ordering uses timestamp and ID for stable ties.
- No database migration or agent submission behavior changes are
required.

## Model Used

- OpenAI Codex, GPT-6 family, with reasoning, tool use, browser
interaction, and code execution. The exact backend model ID and
context-window size are not exposed in this session.

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 14:21:13 -05:00
1 parent d747752496
commit e6dcd1e8e9
26 files changed
+830 -22

No files matched your search

+5
View File
@@ -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
+5
View File
@@ -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
+5
View File
@@ -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
+15 -2
View File
@@ -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=<message-id>` 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.
+22
View File
@@ -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.
+14
View File
@@ -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.
@@ -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<typeof agentCommentaryQuerySchema>;
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;
}
@@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let reader: ReturnType<typeof agentCommentaryReader>;
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<string, unknown> = { 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);
});
});
+11 -2
View File
@@ -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 });
+37 -3
View File
@@ -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);
+22
View File
@@ -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"]),
@@ -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<string>`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<AgentCommentaryPage> {
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<AgentCommentaryDetail | null> {
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;
},
};
}
+9 -6
View File
@@ -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 () => {
+5 -4
View File
@@ -368,12 +368,13 @@ function boardRoutes(streamlinedUiEnabled: boolean, combinedInboxTasksEnabled: b
<Route path="approvals/all" element={<Approvals />} />
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
<Route path="activity" element={streamlinedUiEnabled ? <CompanyActivity /> : <ProductionSurface><ProductionCompanyActivity /></ProductionSurface>} />
<Route path="activity/commentary" element={<AuditHub section="commentary" />} />
<Route path="activity/runs" element={<AuditHub section="runs" />} />
<Route path="activity/costs" element={<AuditHub section="costs" />} />
<Route path="activity/budgets" element={<AuditHub section="budgets" />} />
<Route path="activity/timeline" element={<AuditHub section="timeline" />} />
{streamlinedUiEnabled ? (
<>
<Route path="activity/runs" element={<AuditHub section="runs" />} />
<Route path="activity/costs" element={<AuditHub section="costs" />} />
<Route path="activity/budgets" element={<AuditHub section="budgets" />} />
<Route path="activity/timeline" element={<AuditHub section="timeline" />} />
<Route path="audit" element={<AuditCompatibilityRedirect to="/activity" forceAgentMode />} />
<Route path="audit/activity" element={<AuditCompatibilityRedirect to="/activity" />} />
<Route path="audit/runs" element={<AuditCompatibilityRedirect to="/activity/runs" />} />
+14
View File
@@ -0,0 +1,14 @@
import type { AgentCommentaryDetail, AgentCommentaryPage, AgentCommentaryQuery } from "@paperclipai/shared";
import { api } from "./client";
export const agentCommentaryApi = {
list(companyId: string, filters: Partial<AgentCommentaryQuery>, 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<AgentCommentaryPage>(`/companies/${companyId}/agent-commentary?${query}`, { signal });
},
get: (companyId: string, id: string, signal?: AbortSignal) =>
api.get<AgentCommentaryDetail>(`/companies/${companyId}/agent-commentary/${encodeURIComponent(id)}`, { signal }),
};
+1
View File
@@ -233,6 +233,7 @@ export function Sidebar() {
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
{/* One entry — /audit merged into the rich Activity feed (PAP-16302). */}
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/activity/commentary" label="Commentary" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
+1
View File
@@ -294,6 +294,7 @@ export function Sidebar({ children }: { children?: ReactNode }) {
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
<SidebarNavItem to="/activity" label="Activity" icon={History} />
<SidebarNavItem to="/activity/commentary" label="Commentary" icon={History} />
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
</SidebarSection>
</>
+4
View File
@@ -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,
@@ -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<typeof createRoot>;
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(
<QueryClientProvider client={client}><ThemeProvider><BrowserRouter>
<AgentCommentary companyId="company-1" />
</BrowserRouter></ThemeProvider></QueryClientProvider>,
));
}
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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());
});
});
+145
View File
@@ -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 <Badge variant="outline" className="gap-1.5 font-normal"><Icon className="size-3" />{kind === "suggestion" ? "Suggestion" : "Complaint"}</Badge>;
}
function CommentaryRow({ entry, onOpen }: { entry: AgentCommentaryEntry; onOpen: (trigger: HTMLButtonElement) => void }) {
return (
<li>
<button type="button" onClick={(event) => onOpen(event.currentTarget)} aria-label={`Read ${entry.kind} from ${entry.agent.name}`}
className="group flex w-full flex-col gap-2 rounded-md px-3 py-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:grid md:grid-cols-12 md:items-start md:gap-4">
<div className="flex items-center justify-between gap-3 md:col-span-2"><AgentIdentity agent={entry.agent} size="sm" /><span className="md:hidden"><MessageKind kind={entry.kind} /></span></div>
<div className="hidden md:col-span-2 md:block"><MessageKind kind={entry.kind} /></div>
<div className="flex min-w-0 flex-col gap-2 md:col-span-6">
<p className="line-clamp-2 whitespace-pre-line break-words text-sm leading-relaxed text-foreground">{entry.preview.trim()}{entry.preview.length >= 280 ? "…" : ""}</p>
{entry.task && <span className="truncate text-xs text-muted-foreground"><span className="font-mono">{entry.task.identifier ?? "Task"}</span> · {entry.task.title}</span>}
</div>
<time dateTime={entry.createdAt} title={new Date(entry.createdAt).toLocaleString()} className="font-mono text-xs text-muted-foreground md:col-span-2 md:text-right">{relativeTime(entry.createdAt)}</time>
</button>
</li>
);
}
function CommentaryDetail({ companyId, id, onClose, triggerRef }: { companyId: string; id: string | null; onClose: () => void; triggerRef: RefObject<HTMLButtonElement | null> }) {
const detail = useQuery({
queryKey: queryKeys.agentCommentary.detail(companyId, id),
queryFn: ({ signal }) => agentCommentaryApi.get(companyId, id!, signal),
enabled: id !== null,
});
const entry = detail.data;
return (
<Dialog open={id !== null} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="flex max-h-(--sz-85vh) flex-col gap-5 sm:max-w-3xl" onCloseAutoFocus={(event) => {
if (triggerRef.current?.isConnected) {
event.preventDefault();
triggerRef.current.focus();
}
}}>
<DialogHeader className="shrink-0 pr-6">
<DialogTitle>{entry?.kind === "complaint" ? "Complaint" : entry ? "Suggestion" : "Commentary"}</DialogTitle>
<DialogDescription>{entry ? `Submitted ${new Date(entry.createdAt).toLocaleString()}` : "Message details and source context."}</DialogDescription>
</DialogHeader>
{detail.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading message…</p> : detail.error ? (
<div role="alert" className="flex flex-col items-start gap-3"><p className="text-sm text-destructive">{detail.error.message}</p><Button variant="outline" size="sm" onClick={() => void detail.refetch()}>Try again</Button></div>
) : entry ? <>
<div className="flex shrink-0 flex-wrap items-center gap-3"><Link to={`/agents/${entry.agent.id}`}><AgentIdentity agent={entry.agent} size="sm" /></Link><MessageKind kind={entry.kind} /></div>
<div className="min-h-0 overflow-y-auto overscroll-contain break-words pr-2"><MarkdownBody>{entry.body}</MarkdownBody></div>
<div className="flex shrink-0 flex-wrap items-center gap-4 border-t border-border pt-4 text-sm">
{entry.task && <Link to={`/issues/${entry.task.identifier ?? entry.task.id}`} className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground"><span className="font-mono">{entry.task.identifier ?? "Source task"}</span><ArrowUpRight className="size-3.5" /></Link>}
<Link to={`/agents/${entry.agent.id}/runs/${entry.runId}`} className="inline-flex items-center gap-1.5 text-muted-foreground hover:text-foreground">View run<ArrowUpRight className="size-3.5" /></Link>
</div>
</> : null}
</DialogContent>
</Dialog>
);
}
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<HTMLButtonElement | null>(null);
const updateFilters = useCallback((patch: Record<string, string>) => {
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 (
<section className="flex flex-col gap-5" aria-label="Commentary">
<div className="flex flex-col gap-1"><h2 className="text-lg font-semibold">Commentary</h2><p className="text-sm text-muted-foreground">Suggestions and complaints from your agents.</p></div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-48 flex-1 sm:max-w-sm"><Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /><Input aria-label="Search commentary" placeholder="Search messages…" maxLength={200} value={search} onChange={(event) => setSearch(event.target.value)} className="pl-9" /></div>
<Select value={agentId} onValueChange={(value) => updateFilters({ agentId: value })}><SelectTrigger aria-label="Filter by agent" className="min-w-40"><SelectValue placeholder="All agents" /></SelectTrigger><SelectContent><SelectItem value={ALL}>All agents</SelectItem>{agents.data?.map((agent) => <SelectItem key={agent.id} value={agent.id}><AgentIdentity agent={agent} size="xs" /></SelectItem>)}</SelectContent></Select>
<Select value={kind ?? ALL} onValueChange={(value) => updateFilters({ kind: value })}><SelectTrigger aria-label="Filter by message type" className="min-w-36"><SelectValue /></SelectTrigger><SelectContent><SelectItem value={ALL}>All types</SelectItem><SelectItem value="suggestion">Suggestions</SelectItem><SelectItem value="complaint">Complaints</SelectItem></SelectContent></Select>
{hasFilters && <Button variant="ghost" size="sm" onClick={clearFilters}>Clear filters</Button>}
</div>
{agents.error && <p role="alert" className="text-sm text-destructive">Could not load agent filters. <button type="button" className="underline" onClick={() => void agents.refetch()}>Try again</button></p>}
{messages.isPending ? <p role="status" className="py-12 text-center text-sm text-muted-foreground">Loading commentary…</p> : messages.error ? (
<div role="alert" className="flex flex-col items-center gap-3 py-12"><p className="text-sm text-destructive">{messages.error.message}</p><Button variant="outline" size="sm" onClick={() => void messages.refetch()}>Try again</Button></div>
) : messages.data?.items.length === 0 ? (
<EmptyState icon={Lightbulb} message={offset > 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 ? <>
<div>
<div aria-hidden="true" className="hidden grid-cols-12 gap-4 border-b border-border px-3 pb-3 text-xs font-medium text-muted-foreground md:grid"><span className="col-span-2">Agent</span><span className="col-span-2">Type</span><span className="col-span-6">Message</span><span className="col-span-2 text-right">Submitted</span></div>
<ul aria-label="Commentary messages" className="divide-y divide-border">{messages.data.items.map((entry) => <CommentaryRow key={entry.id} entry={entry} onOpen={(trigger) => { triggerRef.current = trigger; selectEntry(entry.id); }} />)}</ul>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
<p role="status" className="font-mono text-xs text-muted-foreground">{offset + 1}–{offset + messages.data.items.length} of {messages.data.total} messages</p>
<div className="flex gap-2"><Button variant="outline" size="sm" aria-label="Previous page" disabled={offset === 0} onClick={() => updateFilters({ offset: String(Math.max(0, offset - PAGE_SIZE)) })}><ChevronLeft className="size-4" />Previous</Button><Button variant="outline" size="sm" aria-label="Next page" disabled={offset + messages.data.items.length >= messages.data.total} onClick={() => updateFilters({ offset: String(offset + PAGE_SIZE) })}>Next<ChevronRight className="size-4" /></Button></div>
</div>
</> : null}
<CommentaryDetail key={companyId} companyId={companyId} id={selectedId} onClose={() => selectEntry(null)} triggerRef={triggerRef} />
</section>
);
}
+21 -3
View File
@@ -40,6 +40,12 @@ vi.mock("./AuditFeed", () => ({
),
}));
vi.mock("./AgentCommentary", () => ({
AgentCommentary: ({ companyId }: { companyId: string }) => (
<div data-testid="agent-commentary" data-company={companyId} />
),
}));
vi.mock("./AuditRuns", () => ({
AuditRuns: ({ companyId, routineId }: { companyId: string; routineId?: string }) => (
<div data-testid="audit-runs" data-company={companyId} data-routine={routineId} />
@@ -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(<AuditHub section={section} />));
}
@@ -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<HTMLElement>('[data-testid="agent-commentary"]')?.dataset.company).toBe("company-1");
const tab = Array.from(container.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-testid="audit-timeline"]')?.dataset.embedded)
.toBe("true");
});
+4 -1
View File
@@ -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 }) {
<PageTabBar items={AUDIT_SECTIONS} value={section} align="start" />
</Tabs>
{section === "activity" && routineId ? (
{section === "commentary" ? (
<AgentCommentary key={selectedCompanyId} companyId={selectedCompanyId} />
) : section === "activity" && routineId ? (
<RoutineAuditActivity companyId={selectedCompanyId} routineId={routineId} />
) : section === "activity" ? (
<AuditFeed
@@ -10,6 +10,7 @@ import {
describe("audit navigation", () => {
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");
+2 -1
View File
@@ -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" },
@@ -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 <PluginLauncherProvider><Routes><Route path="/:companyPrefix" element={<Layout />}><Route path="activity/commentary" element={<AuditHub section="commentary" />} /><Route path="activity" element={<AuditHub section="activity" />} /></Route></Routes></PluginLauncherProvider>;
}
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<typeof CommentaryJourney>;
export default meta;
type Story = StoryObj<typeof meta>;
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" } };
@@ -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 }) },
]);
}