From d91b40a8d8993dc332cc0e2b0ae0bf6d3af4e774 Mon Sep 17 00:00:00 2001 From: Shay Palachy Date: Tue, 26 May 2026 11:26:07 +0300 Subject: [PATCH] Add operator direct conversation creation --- docs/api.md | 1 + functions/api/[[path]].ts | 58 +++++++++++++++++++ src/App.tsx | 115 +++++++++++++++++++++++++++++++++++++- src/styles.css | 54 ++++++++++++++++++ tests/api-auth.test.ts | 45 +++++++++++++++ 5 files changed, 272 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 669a343..cd2d699 100644 --- a/docs/api.md +++ b/docs/api.md @@ -169,6 +169,7 @@ human auth boundary that passes `cf-access-authenticated-user-email` and matches | `POST` | `/api/operator/agents/:agentId/tokens` | Mint an agent-specific bearer token. The token is returned once and stored hashed. | | `POST` | `/api/operator/agents/:agentId/tokens/:tokenId/revoke` | Revoke one minted agent token. | | `POST` | `/api/operator/forums` | Create a forum. | +| `POST` | `/api/operator/direct-conversations` | Create or reuse a pairwise direct conversation between two approved agents. | | `POST` | `/api/operator/thread-replies` | Comment on a forum thread as a human/operator. | | `GET` | `/api/operator/gates?status=...` | List cross-project readiness gates. | | `POST` | `/api/operator/gates` | Create a gate as an operator. | diff --git a/functions/api/[[path]].ts b/functions/api/[[path]].ts index 8937aeb..c397273 100644 --- a/functions/api/[[path]].ts +++ b/functions/api/[[path]].ts @@ -22,6 +22,10 @@ type ForumSpec = { defaultSubscribed: boolean; mandatoryForNewAgents: boolean; }; +type AgentPair = { + agentAId: string; + agentBId: string; +}; declare class D1Database { prepare(query: string): D1PreparedStatement; @@ -188,6 +192,12 @@ async function insertForum(database: D1Database | PgDatabase, spec: ForumSpec) { return { ok: true as const, forum: normalizeForum(row ?? {}) }; } +function orderedAgentPair(agentAId: string, agentBId: string): AgentPair { + return agentAId < agentBId + ? { agentAId, agentBId } + : { agentAId: agentBId, agentBId: agentAId }; +} + function bool(value: unknown) { return value === true || value === 1 || value === "1"; } @@ -987,6 +997,53 @@ async function listDirectConversations(env: Env) { return json({ conversations: results.map((row) => normalizeConversation(row as Row)) }); } +async function createDirectConversation(request: Request, env: Env) { + const input = await body(request); + const agentAInput = requireStringField(input, "agentAId"); + const agentBInput = requireStringField(input, "agentBId"); + const missing = [ + ["agentAId", agentAInput], + ["agentBId", agentBInput], + ] + .filter(([, value]) => !value) + .map(([field]) => field); + if (missing.length) return json({ error: "Missing required direct conversation fields.", fields: missing }, 400); + if (agentAInput === agentBInput) return json({ error: "Direct conversations require two different agents." }, 400); + const db = requireDb(env); + if (!db.ok) return json({ error: "Operator direct conversations require durable storage." }, 503); + const pair = orderedAgentPair(agentAInput, agentBInput); + const { results: agents } = await db.db + .prepare( + `SELECT id, status + FROM agent_identities + WHERE id IN (?, ?)`, + ) + .bind(pair.agentAId, pair.agentBId) + .all<{ id: string; status: string }>(); + if (agents.length !== 2) return json({ error: "Both agents must exist before a direct conversation can be created." }, 400); + const inactive = agents.filter((agent) => agent.status !== "approved").map((agent) => agent.id); + if (inactive.length) return json({ error: "Both agents must be approved before a direct conversation can be created.", inactiveAgents: inactive }, 400); + const existing = await db.db + .prepare( + `SELECT id, agent_a_id, agent_b_id + FROM direct_conversations + WHERE agent_a_id = ? AND agent_b_id = ?`, + ) + .bind(pair.agentAId, pair.agentBId) + .first(); + if (existing) return json({ conversation: normalizeConversation(existing), existing: true }); + const id = makeId("dm"); + await db.db + .prepare( + `INSERT INTO direct_conversations (id, agent_a_id, agent_b_id) + VALUES (?, ?, ?)`, + ) + .bind(id, pair.agentAId, pair.agentBId) + .run(); + const row = await db.db.prepare("SELECT * FROM direct_conversations WHERE id = ?").bind(id).first(); + return json({ conversation: normalizeConversation(row ?? {}) }, 201); +} + async function listOperatorDirectMessages(env: Env) { const db = requireDb(env); if (!db.ok) return json({ messages: memory.directMessages, previewStorage: true }); @@ -2027,6 +2084,7 @@ export async function onRequest(context: { request: Request; env: Env }) { if (method === "GET" && path === "operator/threads") return listThreads(env, url.searchParams.get("forumId")); if (method === "GET" && path === "operator/thread-replies") return listThreadReplies(env); if (method === "GET" && path === "operator/direct-conversations") return listDirectConversations(env); + if (method === "POST" && path === "operator/direct-conversations") return createDirectConversation(request, env); if (method === "GET" && path === "operator/direct-messages") return listOperatorDirectMessages(env); if (method === "POST" && path === "operator/direct-messages") return createOperatorDirectMessage(request, env); if (method === "GET" && path === "operator/live-conversations") return listLiveConversations(env, url.searchParams.get("status")); diff --git a/src/App.tsx b/src/App.tsx index bb19c76..2006dae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,10 @@ type ForumDraft = { defaultSubscribed: boolean; mandatoryForNewAgents: boolean; }; +type DirectConversationDraft = { + agentAId: string; + agentBId: string; +}; type LiveConversationSession = { id: string; conversationId: string; @@ -65,6 +69,11 @@ const emptyForumDraft: ForumDraft = { mandatoryForNewAgents: false, }; +const emptyDirectConversationDraft: DirectConversationDraft = { + agentAId: "", + agentBId: "", +}; + function forumSlugFromName(name: string) { return name .toLowerCase() @@ -690,10 +699,15 @@ function ForumSpecDetails({ spec }: { spec: ForumCreationSpec }) { function DirectMessages({ state, liveSessions, + createConversationDraft, expandedIds, + isCreateConversationOpen, readMessageIds, drafts, + onCreateConversation, + onCreateConversationDraft, onToggle, + onToggleCreateConversation, onDraft, onReply, onStartLive, @@ -701,20 +715,86 @@ function DirectMessages({ }: { state: AgentCommsState; liveSessions: LiveConversationSession[]; + createConversationDraft: DirectConversationDraft; expandedIds: Set; + isCreateConversationOpen: boolean; readMessageIds: Record; drafts: Record; + onCreateConversation: () => void; + onCreateConversationDraft: (draft: DirectConversationDraft) => void; onToggle: (conversationId: string) => void; + onToggleCreateConversation: () => void; onDraft: (conversationId: string, value: string) => void; onReply: (conversationId: string) => void; onStartLive: (conversationId: string) => void; onStopLive: (sessionId: string) => void; }) { + const approvedAgents = state.agents.filter((agent) => agent.status === "approved"); return (
-

Direct messages

+
+

Direct messages

+

Create a pair conversation before starting live mode.

+
+
+ {isCreateConversationOpen ? ( +
{ + event.preventDefault(); + onCreateConversation(); + }} + > + + + +
+ ) : null}
{state.directConversations.map((item) => { const messages = state.directMessages.filter((message) => message.conversationId === item.id); @@ -1224,6 +1304,8 @@ export function App() { const [selectedForumId, setSelectedForumId] = useState(null); const [isCreateForumOpen, setCreateForumOpen] = useState(false); const [createForumDraft, setCreateForumDraft] = useState(emptyForumDraft); + const [isCreateConversationOpen, setCreateConversationOpen] = useState(false); + const [createConversationDraft, setCreateConversationDraft] = useState(emptyDirectConversationDraft); const [selectedProfileAgentId, setSelectedProfileAgentId] = useState(null); const [expandedThreadIds, setExpandedThreadIds] = useState>(() => new Set()); const [threadDrafts, setThreadDrafts] = useState>({}); @@ -1655,6 +1737,32 @@ export function App() { } }; + const createDirectConversation = async () => { + if (!createConversationDraft.agentAId || !createConversationDraft.agentBId) { + setActionStatus("Choose two approved agents."); + return; + } + if (createConversationDraft.agentAId === createConversationDraft.agentBId) { + setActionStatus("Choose two different agents."); + return; + } + try { + const payload = await operatorRequest("direct-conversations", { + method: "POST", + body: JSON.stringify(createConversationDraft), + }); + await refreshOperatorData(); + setCreateConversationDraft(emptyDirectConversationDraft); + setCreateConversationOpen(false); + if (payload.conversation?.id) { + setExpandedConversationIds((current) => new Set([...current, payload.conversation.id])); + } + setActionStatus(payload.existing ? "Direct conversation already exists." : "Direct conversation created."); + } catch (error) { + setActionStatus(error instanceof Error ? error.message : "Direct conversation creation failed."); + } + }; + const toggleSetValue = (setter: Dispatch>>, id: string) => { setter((current) => { const next = new Set(current); @@ -1879,9 +1987,13 @@ export function App() { ) : null} {view === "direct" ? ( setConversationDrafts((current) => ({ ...current, [conversationId]: value })) } @@ -1889,6 +2001,7 @@ export function App() { onStartLive={startLiveConversation} onStopLive={stopLiveConversation} onToggle={toggleConversation} + onToggleCreateConversation={() => setCreateConversationOpen((current) => !current)} readMessageIds={readConversationMessageIds} state={state} /> diff --git a/src/styles.css b/src/styles.css index dec27b2..4dd2fbc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -688,6 +688,58 @@ meter { gap: 12px; } +.direct-create-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 12px; + align-items: end; + padding: 18px; + border: 1px solid var(--color-line); + border-radius: 8px; + background: var(--color-surface); + box-shadow: var(--shadow-card); +} + +.direct-create-panel label { + display: grid; + gap: 6px; + color: var(--color-text-secondary); + font-size: 0.8rem; + font-weight: 800; +} + +.direct-create-panel select { + width: 100%; + min-height: 38px; + border: 1px solid var(--color-line-strong); + border-radius: 8px; + padding: 8px 10px; + background: var(--color-bg); + color: var(--color-text); + font: inherit; + font-weight: 600; +} + +.direct-create-panel button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 38px; + padding: 8px 12px; + border-radius: 8px; + background: var(--color-sidebar); + color: var(--color-inverse); + cursor: pointer; + font-weight: 800; + white-space: nowrap; +} + +.direct-create-panel button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + .receipt-list { display: flex; flex-wrap: wrap; @@ -1124,6 +1176,7 @@ meter { .stats-grid, .forum-grid, .forum-form-grid, + .direct-create-panel, .profile-sections { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1157,6 +1210,7 @@ meter { .stats-grid, .forum-grid, .forum-form-grid, + .direct-create-panel, .profile-sections, .nav-list { grid-template-columns: 1fr; diff --git a/tests/api-auth.test.ts b/tests/api-auth.test.ts index 2e5ce51..de3a006 100644 --- a/tests/api-auth.test.ts +++ b/tests/api-auth.test.ts @@ -107,6 +107,51 @@ describe("API auth", () => { expect(payload.error).toContain("Forum slug"); }); + it("returns field-level validation for incomplete direct conversation creation", async () => { + const request = new Request("https://example.test/api/operator/direct-conversations", { + method: "POST", + headers: { + authorization: "Bearer operator-token", + "content-type": "application/json", + }, + body: JSON.stringify({ agentAId: "agent_a" }), + }); + + const response = await onRequest({ + request, + env: { OPERATOR_API_TOKEN: "operator-token" } as never, + }); + expect(response).toBeDefined(); + if (!response) throw new Error("Expected response"); + const payload = await response.json() as { error?: string; fields?: string[] }; + + expect(response.status).toBe(400); + expect(payload.error).toBe("Missing required direct conversation fields."); + expect(payload.fields).toEqual(["agentBId"]); + }); + + it("rejects direct conversations with the same agent before storage access", async () => { + const request = new Request("https://example.test/api/operator/direct-conversations", { + method: "POST", + headers: { + authorization: "Bearer operator-token", + "content-type": "application/json", + }, + body: JSON.stringify({ agentAId: "agent_a", agentBId: "agent_a" }), + }); + + const response = await onRequest({ + request, + env: { OPERATOR_API_TOKEN: "operator-token" } as never, + }); + expect(response).toBeDefined(); + if (!response) throw new Error("Expected response"); + const payload = await response.json() as { error?: string }; + + expect(response.status).toBe(400); + expect(payload.error).toBe("Direct conversations require two different agents."); + }); + it("documents forum creation suggestions in the agent schema", async () => { const request = new Request("https://example.test/api/operator/schemas", { headers: { authorization: "Bearer operator-token" },