מוקאפ 18i v2 (מאושר ב-Claude Design) + תיקון-מקור להצטברות pending interactions. UI (agent-activity-feed.tsx): - רוסטר-סוכנים קומפקטי, כל הסוכנים בשורה אחת (grid-cols-9); הוסרה שורת "מה הסוכן עושה עכשיו" — היא שייכה תגובה לפי role-fallback, כך שסוכנים בעלי אותו תפקיד הציגו טקסט זהה. - כל אירוע "ממתין לתשובתך" מקופל כאקורדיון (הראשון פתוח). - כפתור "התעלם" לכל בקשה ממתינה — מבטל שאלה כפולה/מיושנת בלי להעיר את הסוכן. - ציר-הזמן מקובץ לפי משימה (issue), כל קבוצה אקורדיון עם מזהה+סטטוס+מונה. Backend (#215 — מניעת הצטברות pending): - cancel_interaction: ביטול interaction בודד ישירות ל-cancelled, ללא wake_assignee (resolve רגיל היה מעיר את הסוכן). אין triggers על הטבלה. - reap_stale_interactions: reaper שמבטל pending על issues סגורים (done/cancelled) — המקור הדומיננטי לערימה. רץ כל 15 דק' ב-lifespan. - endpoint POST /api/cases/{case}/agents/interaction-dismiss (מאחורי כפתור "התעלם"). הכל דרך agent_platform_port (G12). supersede-at-creation נדחה (תועד ב-#215): ביטול-אוטומטי של שאלה על issue פעיל אחד על-פני אחר אינו דטרמיניסטי; הליבה הבטוחה = reaper-ליתומים + כפתור-ביטול, והשורש האמיתי הוא ריסון לולאות-recovery של Paperclip. Invariants: מקיים G12 (מגע-Paperclip רק דרך הפורט), G2 (אין מסלול מקביל — מרחיב את endpoints הסוכנים הקיימים), §6 (אין בליעת-שגיאות שקטה — הביטול מחזיר {ok,error}, ה-reaper מתעד warning). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
224 lines
6.3 KiB
TypeScript
224 lines
6.3 KiB
TypeScript
/**
|
|
* Paperclip agent ACTIVITY hooks — mirror live agent work into the Legal-AI UI.
|
|
*
|
|
* Frontend platform-presentation module (G12 / docs/spec/X15): the web-ui is a
|
|
* presentation layer, and showing the agent platform's live activity is a
|
|
* legitimate, declared use of platform data. Distinct from
|
|
* `paperclip-agents.ts`, which is the settings/management view of an agent's
|
|
* full config — hence the activity DTO here is `PaperclipAgentStatus`, not the
|
|
* config `PaperclipAgent` (the two are different projections; the name no
|
|
* longer collides).
|
|
*/
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { apiRequest } from "./client";
|
|
|
|
// ── Types ────────────────────────────────────────────────────────
|
|
|
|
export type PaperclipIssue = {
|
|
id: string;
|
|
title: string;
|
|
status: string;
|
|
identifier: string;
|
|
priority: string;
|
|
assignee_name: string | null;
|
|
started_at: string | null;
|
|
completed_at: string | null;
|
|
created_at: string | null;
|
|
company_id: string;
|
|
};
|
|
|
|
export type PaperclipComment = {
|
|
id: string;
|
|
issue_id: string;
|
|
body: string;
|
|
created_at: string | null;
|
|
author_agent_id: string | null;
|
|
author_user_id: string | null;
|
|
agent_name: string | null;
|
|
agent_role: string | null;
|
|
agent_icon: string | null;
|
|
};
|
|
|
|
export type PaperclipAgentStatus = {
|
|
id: string;
|
|
name: string;
|
|
role: string;
|
|
title: string | null;
|
|
status: string;
|
|
icon: string | null;
|
|
last_heartbeat_at: string | null;
|
|
};
|
|
|
|
export type InteractionKind =
|
|
| "ask_user_questions"
|
|
| "request_confirmation"
|
|
| "suggest_tasks";
|
|
|
|
export type InteractionStatus =
|
|
| "pending"
|
|
| "answered"
|
|
| "accepted"
|
|
| "rejected"
|
|
| "expired"
|
|
| "failed";
|
|
|
|
export type InteractionOption = {
|
|
id: string;
|
|
label: string;
|
|
description?: string | null;
|
|
};
|
|
|
|
export type InteractionQuestion = {
|
|
id: string;
|
|
prompt: string;
|
|
selectionMode?: "single" | "multi";
|
|
required?: boolean;
|
|
options: InteractionOption[];
|
|
};
|
|
|
|
export type InteractionTask = {
|
|
clientKey: string;
|
|
parentClientKey?: string | null;
|
|
title: string;
|
|
description?: string | null;
|
|
};
|
|
|
|
/** Free-form payload — shape depends on `kind`. Common fields surfaced for the
|
|
* UI; everything else is preserved on the wire. */
|
|
export type InteractionPayload = {
|
|
version?: number;
|
|
submitLabel?: string;
|
|
acceptLabel?: string;
|
|
rejectLabel?: string;
|
|
questions?: InteractionQuestion[];
|
|
tasks?: InteractionTask[];
|
|
body?: string;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type Interaction = {
|
|
id: string;
|
|
issue_id: string;
|
|
kind: InteractionKind;
|
|
status: InteractionStatus;
|
|
title: string | null;
|
|
summary: string | null;
|
|
payload: InteractionPayload;
|
|
result: Record<string, unknown> | null;
|
|
created_at: string | null;
|
|
resolved_at: string | null;
|
|
};
|
|
|
|
export type AgentActivityResponse = {
|
|
issues: PaperclipIssue[];
|
|
comments: PaperclipComment[];
|
|
agents: PaperclipAgentStatus[];
|
|
interactions: Interaction[];
|
|
};
|
|
|
|
export type InteractionAction = "respond" | "accept" | "reject";
|
|
|
|
export type InteractionSubmitVars = {
|
|
issue_id: string;
|
|
interaction_id: string;
|
|
action: InteractionAction;
|
|
payload: Record<string, unknown>;
|
|
};
|
|
|
|
// ── Query Keys ───────────────────────────────────────────────────
|
|
|
|
export const agentKeys = {
|
|
activity: (caseNumber: string) =>
|
|
["agents", "activity", caseNumber] as const,
|
|
};
|
|
|
|
// ── Hooks ────────────────────────────────────────────────────────
|
|
|
|
export function useAgentActivity(caseNumber: string | undefined) {
|
|
return useQuery({
|
|
queryKey: agentKeys.activity(caseNumber ?? ""),
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<AgentActivityResponse>(
|
|
`/api/cases/${caseNumber}/agents`,
|
|
{ signal },
|
|
),
|
|
enabled: !!caseNumber,
|
|
refetchInterval: 10_000,
|
|
});
|
|
}
|
|
|
|
export function useSendComment(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (vars: { body: string; issue_id?: string }) =>
|
|
apiRequest<{ comment_id: string; issue_id: string; issue_identifier: string }>(
|
|
`/api/cases/${caseNumber}/agents/comment`,
|
|
{ method: "POST", body: vars },
|
|
),
|
|
onSuccess: () => {
|
|
if (caseNumber) {
|
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useSubmitInteraction(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (vars: InteractionSubmitVars) =>
|
|
apiRequest<Interaction>(
|
|
`/api/cases/${caseNumber}/agents/interaction-response`,
|
|
{ method: "POST", body: vars },
|
|
),
|
|
onSuccess: () => {
|
|
if (caseNumber) {
|
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Dismiss a pending interaction WITHOUT waking the issue assignee — used for
|
|
* stale/duplicate questions. Backend marks it `cancelled` directly (the
|
|
* `wake_assignee` continuation policy means a normal resolve would wake the
|
|
* agent; dismiss must not). */
|
|
export function useDismissInteraction(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (vars: { issue_id: string; interaction_id: string }) =>
|
|
apiRequest<{ ok: boolean; cancelled: boolean }>(
|
|
`/api/cases/${caseNumber}/agents/interaction-dismiss`,
|
|
{ method: "POST", body: vars },
|
|
),
|
|
onSuccess: () => {
|
|
if (caseNumber) {
|
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export type AgentResetResult = {
|
|
ok: boolean;
|
|
reassigned_issues: { id: string; identifier: string }[];
|
|
reset_agents: { id: string; name: string; ok: boolean; error?: string }[];
|
|
};
|
|
|
|
export function useResetCaseAgents(caseNumber: string | undefined) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: () =>
|
|
apiRequest<AgentResetResult>(
|
|
`/api/cases/${caseNumber}/agents/reset`,
|
|
{ method: "POST" },
|
|
),
|
|
onSuccess: () => {
|
|
if (caseNumber) {
|
|
qc.invalidateQueries({ queryKey: agentKeys.activity(caseNumber) });
|
|
}
|
|
},
|
|
});
|
|
}
|