Add agent Mine inbox API surface
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
4fd62a3d91
commit
ce4536d1fa
9 changed files with 114 additions and 4 deletions
|
|
@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [
|
||||||
] as const;
|
] as const;
|
||||||
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
|
export type IssueStatus = (typeof ISSUE_STATUSES)[number];
|
||||||
|
|
||||||
|
export const INBOX_MINE_ISSUE_STATUSES = [
|
||||||
|
"backlog",
|
||||||
|
"todo",
|
||||||
|
"in_progress",
|
||||||
|
"in_review",
|
||||||
|
"blocked",
|
||||||
|
"done",
|
||||||
|
] as const;
|
||||||
|
export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(",");
|
||||||
|
|
||||||
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
|
export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const;
|
||||||
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
export type IssuePriority = (typeof ISSUE_PRIORITIES)[number];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ export {
|
||||||
AGENT_ROLE_LABELS,
|
AGENT_ROLE_LABELS,
|
||||||
AGENT_ICON_NAMES,
|
AGENT_ICON_NAMES,
|
||||||
ISSUE_STATUSES,
|
ISSUE_STATUSES,
|
||||||
|
INBOX_MINE_ISSUE_STATUSES,
|
||||||
|
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
ISSUE_PRIORITIES,
|
ISSUE_PRIORITIES,
|
||||||
ISSUE_ORIGIN_KINDS,
|
ISSUE_ORIGIN_KINDS,
|
||||||
GOAL_LEVELS,
|
GOAL_LEVELS,
|
||||||
|
|
@ -344,6 +346,7 @@ export {
|
||||||
upsertAgentInstructionsFileSchema,
|
upsertAgentInstructionsFileSchema,
|
||||||
updateAgentInstructionsPathSchema,
|
updateAgentInstructionsPathSchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
|
agentMineInboxQuerySchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
resetAgentSessionSchema,
|
resetAgentSessionSchema,
|
||||||
testAdapterEnvironmentSchema,
|
testAdapterEnvironmentSchema,
|
||||||
|
|
@ -356,6 +359,7 @@ export {
|
||||||
type UpsertAgentInstructionsFile,
|
type UpsertAgentInstructionsFile,
|
||||||
type UpdateAgentInstructionsPath,
|
type UpdateAgentInstructionsPath,
|
||||||
type CreateAgentKey,
|
type CreateAgentKey,
|
||||||
|
type AgentMineInboxQuery,
|
||||||
type WakeAgent,
|
type WakeAgent,
|
||||||
type ResetAgentSession,
|
type ResetAgentSession,
|
||||||
type TestAdapterEnvironment,
|
type TestAdapterEnvironment,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import {
|
||||||
AGENT_ICON_NAMES,
|
AGENT_ICON_NAMES,
|
||||||
AGENT_ROLES,
|
AGENT_ROLES,
|
||||||
AGENT_STATUSES,
|
AGENT_STATUSES,
|
||||||
|
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
import { envConfigSchema } from "./secret.js";
|
import { envConfigSchema } from "./secret.js";
|
||||||
|
|
||||||
|
|
@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({
|
||||||
|
|
||||||
export type CreateAgentKey = z.infer<typeof createAgentKeySchema>;
|
export type CreateAgentKey = z.infer<typeof createAgentKeySchema>;
|
||||||
|
|
||||||
|
export const agentMineInboxQuerySchema = z.object({
|
||||||
|
userId: z.string().trim().min(1),
|
||||||
|
status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AgentMineInboxQuery = z.infer<typeof agentMineInboxQuerySchema>;
|
||||||
|
|
||||||
export const wakeAgentSchema = z.object({
|
export const wakeAgentSchema = z.object({
|
||||||
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
|
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
|
||||||
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),
|
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ export {
|
||||||
upsertAgentInstructionsFileSchema,
|
upsertAgentInstructionsFileSchema,
|
||||||
updateAgentInstructionsPathSchema,
|
updateAgentInstructionsPathSchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
|
agentMineInboxQuerySchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
resetAgentSessionSchema,
|
resetAgentSessionSchema,
|
||||||
testAdapterEnvironmentSchema,
|
testAdapterEnvironmentSchema,
|
||||||
|
|
@ -97,6 +98,7 @@ export {
|
||||||
type UpsertAgentInstructionsFile,
|
type UpsertAgentInstructionsFile,
|
||||||
type UpdateAgentInstructionsPath,
|
type UpdateAgentInstructionsPath,
|
||||||
type CreateAgentKey,
|
type CreateAgentKey,
|
||||||
|
type AgentMineInboxQuery,
|
||||||
type WakeAgent,
|
type WakeAgent,
|
||||||
type ResetAgentSession,
|
type ResetAgentSession,
|
||||||
type TestAdapterEnvironment,
|
type TestAdapterEnvironment,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import request from "supertest";
|
import request from "supertest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||||
import { agentRoutes } from "../routes/agents.js";
|
import { agentRoutes } from "../routes/agents.js";
|
||||||
import { errorHandler } from "../middleware/index.js";
|
import { errorHandler } from "../middleware/index.js";
|
||||||
|
|
||||||
|
|
@ -272,4 +273,42 @@ describe("agent permission routes", () => {
|
||||||
expect(res.body.access.canAssignTasks).toBe(true);
|
expect(res.body.access.canAssignTasks).toBe(true);
|
||||||
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
expect(res.body.access.taskAssignSource).toBe("agent_creator");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes a dedicated agent route for the inbox mine view", async () => {
|
||||||
|
mockIssueService.list.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-910",
|
||||||
|
title: "Inbox follow-up",
|
||||||
|
status: "todo",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
type: "agent",
|
||||||
|
agentId,
|
||||||
|
companyId,
|
||||||
|
runId: "run-1",
|
||||||
|
source: "agent_key",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get("/api/agents/me/inbox/mine")
|
||||||
|
.query({ userId: "board-user" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockIssueService.list).toHaveBeenCalledWith(companyId, {
|
||||||
|
touchedByUserId: "board-user",
|
||||||
|
inboxArchivedByUserId: "board-user",
|
||||||
|
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
|
});
|
||||||
|
expect(res.body).toEqual([
|
||||||
|
{
|
||||||
|
id: "issue-1",
|
||||||
|
identifier: "PAP-910",
|
||||||
|
title: "Inbox follow-up",
|
||||||
|
status: "todo",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db
|
||||||
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
agentSkillSyncSchema,
|
agentSkillSyncSchema,
|
||||||
|
agentMineInboxQuerySchema,
|
||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
createAgentHireSchema,
|
createAgentHireSchema,
|
||||||
createAgentSchema,
|
createAgentSchema,
|
||||||
|
|
@ -1006,6 +1007,23 @@ export function agentRoutes(db: Db) {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/agents/me/inbox/mine", async (req, res) => {
|
||||||
|
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
|
||||||
|
res.status(401).json({ error: "Agent authentication required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = agentMineInboxQuerySchema.parse(req.query);
|
||||||
|
const issuesSvc = issueService(db);
|
||||||
|
const rows = await issuesSvc.list(req.actor.companyId, {
|
||||||
|
touchedByUserId: query.userId,
|
||||||
|
inboxArchivedByUserId: query.userId,
|
||||||
|
status: query.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/agents/:id", async (req, res) => {
|
router.get("/agents/:id", async (req, res) => {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const agent = await svc.getById(id);
|
const agent = await svc.getById(id);
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,7 @@ PATCH /api/agents/{agentId}/instructions-path
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------ |
|
| ----------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| My identity | `GET /api/agents/me` |
|
| My identity | `GET /api/agents/me` |
|
||||||
| My compact inbox | `GET /api/agents/me/inbox-lite` |
|
| My compact inbox | `GET /api/agents/me/inbox-lite` |
|
||||||
|
| Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` |
|
||||||
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
| My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` |
|
||||||
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
| Checkout task | `POST /api/issues/:issueId/checkout` |
|
||||||
| Get task + ancestors | `GET /api/issues/:issueId` |
|
| Get task + ancestors | `GET /api/issues/:issueId` |
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,34 @@ PATCH /api/issues/issue-99
|
||||||
{ "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." }
|
{ "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Worked Example: Report A Board User's Mine Inbox
|
||||||
|
|
||||||
|
When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Board user created the requesting issue.
|
||||||
|
GET /api/issues/issue-200
|
||||||
|
-> { id: "issue-200", createdByUserId: "user-7", ... }
|
||||||
|
|
||||||
|
# Fetch the board user's Mine inbox issues.
|
||||||
|
GET /api/agents/me/inbox/mine?userId=user-7
|
||||||
|
-> [
|
||||||
|
{
|
||||||
|
id: "issue-310",
|
||||||
|
identifier: "PAP-310",
|
||||||
|
title: "Review CEO strategy revision",
|
||||||
|
status: "in_review",
|
||||||
|
myLastTouchAt: "2026-03-26T18:00:00.000Z",
|
||||||
|
lastExternalCommentAt: "2026-03-26T19:10:00.000Z",
|
||||||
|
isUnreadForMe: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Summarize it back to the board in a comment or document.
|
||||||
|
PATCH /api/issues/issue-200
|
||||||
|
{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." }
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Worked Example: Manager Heartbeat
|
## Worked Example: Manager Heartbeat
|
||||||
|
|
@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled`
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | ---------------------------------- | ------------------------------------ |
|
| ------ | ---------------------------------- | ------------------------------------ |
|
||||||
| GET | `/api/agents/me` | Your agent record + chain of command |
|
| GET | `/api/agents/me` | Your agent record + chain of command |
|
||||||
|
| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user |
|
||||||
| GET | `/api/agents/:agentId` | Agent details + chain of command |
|
| GET | `/api/agents/:agentId` | Agent details + chain of command |
|
||||||
| GET | `/api/companies/:companyId/agents` | List all agents in company |
|
| GET | `/api/companies/:companyId/agents` | List all agents in company |
|
||||||
| GET | `/api/companies/:companyId/org` | Org chart tree |
|
| GET | `/api/companies/:companyId/org` | Org chart tree |
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate } from "@/lib/router";
|
import { Link, useLocation, useNavigate } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared";
|
||||||
import { approvalsApi } from "../api/approvals";
|
import { approvalsApi } from "../api/approvals";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
|
@ -68,8 +69,6 @@ type SectionKey =
|
||||||
| "work_items"
|
| "work_items"
|
||||||
| "alerts";
|
| "alerts";
|
||||||
|
|
||||||
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
|
|
||||||
|
|
||||||
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
function firstNonEmptyLine(value: string | null | undefined): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
const line = value.split("\n").map((chunk) => chunk.trim()).find(Boolean);
|
||||||
|
|
@ -595,7 +594,7 @@ export function Inbox() {
|
||||||
issuesApi.list(selectedCompanyId!, {
|
issuesApi.list(selectedCompanyId!, {
|
||||||
touchedByUserId: "me",
|
touchedByUserId: "me",
|
||||||
inboxArchivedByUserId: "me",
|
inboxArchivedByUserId: "me",
|
||||||
status: INBOX_ISSUE_STATUSES,
|
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
}),
|
}),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
@ -607,7 +606,7 @@ export function Inbox() {
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
issuesApi.list(selectedCompanyId!, {
|
issuesApi.list(selectedCompanyId!, {
|
||||||
touchedByUserId: "me",
|
touchedByUserId: "me",
|
||||||
status: INBOX_ISSUE_STATUSES,
|
status: INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
}),
|
}),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue