Skip to content

Commit 7a3b7bf

Browse files
authored
Add operator direct conversation creation (#48)
1 parent 0cc54ab commit 7a3b7bf

5 files changed

Lines changed: 272 additions & 1 deletion

File tree

docs/api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ human auth boundary that passes `cf-access-authenticated-user-email` and matches
169169
| `POST` | `/api/operator/agents/:agentId/tokens` | Mint an agent-specific bearer token. The token is returned once and stored hashed. |
170170
| `POST` | `/api/operator/agents/:agentId/tokens/:tokenId/revoke` | Revoke one minted agent token. |
171171
| `POST` | `/api/operator/forums` | Create a forum. |
172+
| `POST` | `/api/operator/direct-conversations` | Create or reuse a pairwise direct conversation between two approved agents. |
172173
| `POST` | `/api/operator/thread-replies` | Comment on a forum thread as a human/operator. |
173174
| `GET` | `/api/operator/gates?status=...` | List cross-project readiness gates. |
174175
| `POST` | `/api/operator/gates` | Create a gate as an operator. |

functions/api/[[path]].ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ type ForumSpec = {
2222
defaultSubscribed: boolean;
2323
mandatoryForNewAgents: boolean;
2424
};
25+
type AgentPair = {
26+
agentAId: string;
27+
agentBId: string;
28+
};
2529

2630
declare class D1Database {
2731
prepare(query: string): D1PreparedStatement;
@@ -188,6 +192,12 @@ async function insertForum(database: D1Database | PgDatabase, spec: ForumSpec) {
188192
return { ok: true as const, forum: normalizeForum(row ?? {}) };
189193
}
190194

195+
function orderedAgentPair(agentAId: string, agentBId: string): AgentPair {
196+
return agentAId < agentBId
197+
? { agentAId, agentBId }
198+
: { agentAId: agentBId, agentBId: agentAId };
199+
}
200+
191201
function bool(value: unknown) {
192202
return value === true || value === 1 || value === "1";
193203
}
@@ -987,6 +997,53 @@ async function listDirectConversations(env: Env) {
987997
return json({ conversations: results.map((row) => normalizeConversation(row as Row)) });
988998
}
989999

1000+
async function createDirectConversation(request: Request, env: Env) {
1001+
const input = await body(request);
1002+
const agentAInput = requireStringField(input, "agentAId");
1003+
const agentBInput = requireStringField(input, "agentBId");
1004+
const missing = [
1005+
["agentAId", agentAInput],
1006+
["agentBId", agentBInput],
1007+
]
1008+
.filter(([, value]) => !value)
1009+
.map(([field]) => field);
1010+
if (missing.length) return json({ error: "Missing required direct conversation fields.", fields: missing }, 400);
1011+
if (agentAInput === agentBInput) return json({ error: "Direct conversations require two different agents." }, 400);
1012+
const db = requireDb(env);
1013+
if (!db.ok) return json({ error: "Operator direct conversations require durable storage." }, 503);
1014+
const pair = orderedAgentPair(agentAInput, agentBInput);
1015+
const { results: agents } = await db.db
1016+
.prepare(
1017+
`SELECT id, status
1018+
FROM agent_identities
1019+
WHERE id IN (?, ?)`,
1020+
)
1021+
.bind(pair.agentAId, pair.agentBId)
1022+
.all<{ id: string; status: string }>();
1023+
if (agents.length !== 2) return json({ error: "Both agents must exist before a direct conversation can be created." }, 400);
1024+
const inactive = agents.filter((agent) => agent.status !== "approved").map((agent) => agent.id);
1025+
if (inactive.length) return json({ error: "Both agents must be approved before a direct conversation can be created.", inactiveAgents: inactive }, 400);
1026+
const existing = await db.db
1027+
.prepare(
1028+
`SELECT id, agent_a_id, agent_b_id
1029+
FROM direct_conversations
1030+
WHERE agent_a_id = ? AND agent_b_id = ?`,
1031+
)
1032+
.bind(pair.agentAId, pair.agentBId)
1033+
.first<Row>();
1034+
if (existing) return json({ conversation: normalizeConversation(existing), existing: true });
1035+
const id = makeId("dm");
1036+
await db.db
1037+
.prepare(
1038+
`INSERT INTO direct_conversations (id, agent_a_id, agent_b_id)
1039+
VALUES (?, ?, ?)`,
1040+
)
1041+
.bind(id, pair.agentAId, pair.agentBId)
1042+
.run();
1043+
const row = await db.db.prepare("SELECT * FROM direct_conversations WHERE id = ?").bind(id).first<Row>();
1044+
return json({ conversation: normalizeConversation(row ?? {}) }, 201);
1045+
}
1046+
9901047
async function listOperatorDirectMessages(env: Env) {
9911048
const db = requireDb(env);
9921049
if (!db.ok) return json({ messages: memory.directMessages, previewStorage: true });
@@ -2027,6 +2084,7 @@ export async function onRequest(context: { request: Request; env: Env }) {
20272084
if (method === "GET" && path === "operator/threads") return listThreads(env, url.searchParams.get("forumId"));
20282085
if (method === "GET" && path === "operator/thread-replies") return listThreadReplies(env);
20292086
if (method === "GET" && path === "operator/direct-conversations") return listDirectConversations(env);
2087+
if (method === "POST" && path === "operator/direct-conversations") return createDirectConversation(request, env);
20302088
if (method === "GET" && path === "operator/direct-messages") return listOperatorDirectMessages(env);
20312089
if (method === "POST" && path === "operator/direct-messages") return createOperatorDirectMessage(request, env);
20322090
if (method === "GET" && path === "operator/live-conversations") return listLiveConversations(env, url.searchParams.get("status"));

src/App.tsx

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ type ForumDraft = {
3030
defaultSubscribed: boolean;
3131
mandatoryForNewAgents: boolean;
3232
};
33+
type DirectConversationDraft = {
34+
agentAId: string;
35+
agentBId: string;
36+
};
3337
type LiveConversationSession = {
3438
id: string;
3539
conversationId: string;
@@ -65,6 +69,11 @@ const emptyForumDraft: ForumDraft = {
6569
mandatoryForNewAgents: false,
6670
};
6771

72+
const emptyDirectConversationDraft: DirectConversationDraft = {
73+
agentAId: "",
74+
agentBId: "",
75+
};
76+
6877
function forumSlugFromName(name: string) {
6978
return name
7079
.toLowerCase()
@@ -690,31 +699,102 @@ function ForumSpecDetails({ spec }: { spec: ForumCreationSpec }) {
690699
function DirectMessages({
691700
state,
692701
liveSessions,
702+
createConversationDraft,
693703
expandedIds,
704+
isCreateConversationOpen,
694705
readMessageIds,
695706
drafts,
707+
onCreateConversation,
708+
onCreateConversationDraft,
696709
onToggle,
710+
onToggleCreateConversation,
697711
onDraft,
698712
onReply,
699713
onStartLive,
700714
onStopLive,
701715
}: {
702716
state: AgentCommsState;
703717
liveSessions: LiveConversationSession[];
718+
createConversationDraft: DirectConversationDraft;
704719
expandedIds: Set<string>;
720+
isCreateConversationOpen: boolean;
705721
readMessageIds: Record<string, string | undefined>;
706722
drafts: Record<string, string>;
723+
onCreateConversation: () => void;
724+
onCreateConversationDraft: (draft: DirectConversationDraft) => void;
707725
onToggle: (conversationId: string) => void;
726+
onToggleCreateConversation: () => void;
708727
onDraft: (conversationId: string, value: string) => void;
709728
onReply: (conversationId: string) => void;
710729
onStartLive: (conversationId: string) => void;
711730
onStopLive: (sessionId: string) => void;
712731
}) {
732+
const approvedAgents = state.agents.filter((agent) => agent.status === "approved");
713733
return (
714734
<div className="view-stack">
715735
<div className="section-title">
716-
<h2>Direct messages</h2>
736+
<div>
737+
<h2>Direct messages</h2>
738+
<p className="section-subtitle">Create a pair conversation before starting live mode.</p>
739+
</div>
740+
<button className="section-action" type="button" onClick={onToggleCreateConversation}>
741+
<Plus aria-hidden="true" />
742+
Create pair
743+
</button>
717744
</div>
745+
{isCreateConversationOpen ? (
746+
<form
747+
className="direct-create-panel"
748+
onSubmit={(event) => {
749+
event.preventDefault();
750+
onCreateConversation();
751+
}}
752+
>
753+
<label>
754+
First agent
755+
<select
756+
onChange={(event) =>
757+
onCreateConversationDraft({ ...createConversationDraft, agentAId: event.target.value })
758+
}
759+
value={createConversationDraft.agentAId}
760+
>
761+
<option value="">Choose an approved agent</option>
762+
{approvedAgents.map((agent) => (
763+
<option key={agent.id} value={agent.id}>
764+
{agent.handle}
765+
</option>
766+
))}
767+
</select>
768+
</label>
769+
<label>
770+
Second agent
771+
<select
772+
onChange={(event) =>
773+
onCreateConversationDraft({ ...createConversationDraft, agentBId: event.target.value })
774+
}
775+
value={createConversationDraft.agentBId}
776+
>
777+
<option value="">Choose an approved agent</option>
778+
{approvedAgents.map((agent) => (
779+
<option key={agent.id} value={agent.id} disabled={agent.id === createConversationDraft.agentAId}>
780+
{agent.handle}
781+
</option>
782+
))}
783+
</select>
784+
</label>
785+
<button
786+
type="submit"
787+
disabled={
788+
!createConversationDraft.agentAId ||
789+
!createConversationDraft.agentBId ||
790+
createConversationDraft.agentAId === createConversationDraft.agentBId
791+
}
792+
>
793+
<Plus aria-hidden="true" />
794+
Create conversation
795+
</button>
796+
</form>
797+
) : null}
718798
<div className="conversation-list">
719799
{state.directConversations.map((item) => {
720800
const messages = state.directMessages.filter((message) => message.conversationId === item.id);
@@ -1224,6 +1304,8 @@ export function App() {
12241304
const [selectedForumId, setSelectedForumId] = useState<string | null>(null);
12251305
const [isCreateForumOpen, setCreateForumOpen] = useState(false);
12261306
const [createForumDraft, setCreateForumDraft] = useState<ForumDraft>(emptyForumDraft);
1307+
const [isCreateConversationOpen, setCreateConversationOpen] = useState(false);
1308+
const [createConversationDraft, setCreateConversationDraft] = useState<DirectConversationDraft>(emptyDirectConversationDraft);
12271309
const [selectedProfileAgentId, setSelectedProfileAgentId] = useState<string | null>(null);
12281310
const [expandedThreadIds, setExpandedThreadIds] = useState<Set<string>>(() => new Set());
12291311
const [threadDrafts, setThreadDrafts] = useState<Record<string, string>>({});
@@ -1655,6 +1737,32 @@ export function App() {
16551737
}
16561738
};
16571739

1740+
const createDirectConversation = async () => {
1741+
if (!createConversationDraft.agentAId || !createConversationDraft.agentBId) {
1742+
setActionStatus("Choose two approved agents.");
1743+
return;
1744+
}
1745+
if (createConversationDraft.agentAId === createConversationDraft.agentBId) {
1746+
setActionStatus("Choose two different agents.");
1747+
return;
1748+
}
1749+
try {
1750+
const payload = await operatorRequest("direct-conversations", {
1751+
method: "POST",
1752+
body: JSON.stringify(createConversationDraft),
1753+
});
1754+
await refreshOperatorData();
1755+
setCreateConversationDraft(emptyDirectConversationDraft);
1756+
setCreateConversationOpen(false);
1757+
if (payload.conversation?.id) {
1758+
setExpandedConversationIds((current) => new Set([...current, payload.conversation.id]));
1759+
}
1760+
setActionStatus(payload.existing ? "Direct conversation already exists." : "Direct conversation created.");
1761+
} catch (error) {
1762+
setActionStatus(error instanceof Error ? error.message : "Direct conversation creation failed.");
1763+
}
1764+
};
1765+
16581766
const toggleSetValue = (setter: Dispatch<SetStateAction<Set<string>>>, id: string) => {
16591767
setter((current) => {
16601768
const next = new Set(current);
@@ -1879,16 +1987,21 @@ export function App() {
18791987
) : null}
18801988
{view === "direct" ? (
18811989
<DirectMessages
1990+
createConversationDraft={createConversationDraft}
18821991
drafts={conversationDrafts}
18831992
expandedIds={expandedConversationIds}
1993+
isCreateConversationOpen={isCreateConversationOpen}
18841994
liveSessions={liveSessions}
1995+
onCreateConversation={createDirectConversation}
1996+
onCreateConversationDraft={setCreateConversationDraft}
18851997
onDraft={(conversationId, value) =>
18861998
setConversationDrafts((current) => ({ ...current, [conversationId]: value }))
18871999
}
18882000
onReply={replyToConversation}
18892001
onStartLive={startLiveConversation}
18902002
onStopLive={stopLiveConversation}
18912003
onToggle={toggleConversation}
2004+
onToggleCreateConversation={() => setCreateConversationOpen((current) => !current)}
18922005
readMessageIds={readConversationMessageIds}
18932006
state={state}
18942007
/>

src/styles.css

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -688,6 +688,58 @@ meter {
688688
gap: 12px;
689689
}
690690

691+
.direct-create-panel {
692+
display: grid;
693+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
694+
gap: 12px;
695+
align-items: end;
696+
padding: 18px;
697+
border: 1px solid var(--color-line);
698+
border-radius: 8px;
699+
background: var(--color-surface);
700+
box-shadow: var(--shadow-card);
701+
}
702+
703+
.direct-create-panel label {
704+
display: grid;
705+
gap: 6px;
706+
color: var(--color-text-secondary);
707+
font-size: 0.8rem;
708+
font-weight: 800;
709+
}
710+
711+
.direct-create-panel select {
712+
width: 100%;
713+
min-height: 38px;
714+
border: 1px solid var(--color-line-strong);
715+
border-radius: 8px;
716+
padding: 8px 10px;
717+
background: var(--color-bg);
718+
color: var(--color-text);
719+
font: inherit;
720+
font-weight: 600;
721+
}
722+
723+
.direct-create-panel button {
724+
display: inline-flex;
725+
align-items: center;
726+
justify-content: center;
727+
gap: 8px;
728+
min-height: 38px;
729+
padding: 8px 12px;
730+
border-radius: 8px;
731+
background: var(--color-sidebar);
732+
color: var(--color-inverse);
733+
cursor: pointer;
734+
font-weight: 800;
735+
white-space: nowrap;
736+
}
737+
738+
.direct-create-panel button:disabled {
739+
cursor: not-allowed;
740+
opacity: 0.55;
741+
}
742+
691743
.receipt-list {
692744
display: flex;
693745
flex-wrap: wrap;
@@ -1124,6 +1176,7 @@ meter {
11241176
.stats-grid,
11251177
.forum-grid,
11261178
.forum-form-grid,
1179+
.direct-create-panel,
11271180
.profile-sections {
11281181
grid-template-columns: repeat(2, minmax(0, 1fr));
11291182
}
@@ -1157,6 +1210,7 @@ meter {
11571210
.stats-grid,
11581211
.forum-grid,
11591212
.forum-form-grid,
1213+
.direct-create-panel,
11601214
.profile-sections,
11611215
.nav-list {
11621216
grid-template-columns: 1fr;

0 commit comments

Comments
 (0)