| 1 | /** |
| 2 | * Where a conversation happens, as the reply loop sees it: read what was |
| 3 | * said, show that the agent is typing, post its answer. The loop knows |
| 4 | * nothing else about the chat it is in, so the same agent (its definition, |
| 5 | * budget and replies) answers wherever it is reached: g1t's own chat today, |
| 6 | * another chat app the workspace connected later (docs/WORKSPACE.md, |
| 7 | * "Working from another chat app"). Only g1t's adapter exists. |
| 8 | */ |
| 9 | import type { AgentDelivery, ChatMessage, MessageCard, ServiceBinding } from "@g1t/contracts"; |
| 10 | |
| 11 | // By path, not the package: it imports only types, so the adapter is tested under Node. |
| 12 | import { CHAT_MAX_HOPS, chatClient } from "../../../packages/contracts/src/chat.ts"; |
| 13 | |
| 14 | /** One message, as the reply loop reads it, whatever the surface. */ |
| 15 | export type SurfaceMessage = { |
| 16 | id: string; |
| 17 | author: { kind: "user" | "agent"; id: string; name: string; display_name: string }; |
| 18 | body: string; |
| 19 | /** A card's one-line summary, when the message is a card. */ |
| 20 | card: string | null; |
| 21 | created_at: string; |
| 22 | }; |
| 23 | |
| 24 | export interface Surface { |
| 25 | /** The latest `limit` messages of the conversation (the thread, or the DM or channel), oldest first. */ |
| 26 | history(limit: number): Promise<SurfaceMessage[]>; |
| 27 | /** Shows the agent typing. Never throws: it is a courtesy. */ |
| 28 | typing(): Promise<void>; |
| 29 | /** |
| 30 | * Posts as the agent, in the conversation it was asked in, optionally as |
| 31 | * a card (a consult, say). Returns the new message's id. |
| 32 | */ |
| 33 | post(body: string, card?: MessageCard | null): Promise<string>; |
| 34 | /** |
| 35 | * Reacts 👀 to the message that woke the agent, so people see it is on |
| 36 | * it. Never throws, and skipped where it adds nothing (see `reactsIn`). |
| 37 | */ |
| 38 | acknowledge(): Promise<void>; |
| 39 | /** |
| 40 | * Once the reply has posted: 👀 becomes ✅ when it answered (`done`), or |
| 41 | * is taken away when it posted a notice or an apology (`withdrawn`). |
| 42 | * Never throws. |
| 43 | */ |
| 44 | settle(outcome: "done" | "withdrawn"): Promise<void>; |
| 45 | } |
| 46 | |
| 47 | export const SEEN = "👀"; |
| 48 | export const DONE = "✅"; |
| 49 | |
| 50 | /** |
| 51 | * Whether an agent reacts in a conversation: in channels and in direct |
| 52 | * messages with more than one person. In a DM with one person, the typing |
| 53 | * indicator says enough. |
| 54 | */ |
| 55 | export function reactsIn(kind: "channel" | "dm", people: number): boolean { |
| 56 | return kind === "channel" || people > 1; |
| 57 | } |
| 58 | |
| 59 | /** A g1t chat message as the loop reads it. */ |
| 60 | export function fromChat(message: ChatMessage): SurfaceMessage { |
| 61 | const card = message.card |
| 62 | ? [message.card.kind, message.card.title, message.card.detail, message.card.state].filter(Boolean).join(" · ") |
| 63 | : null; |
| 64 | return { |
| 65 | id: message.id, |
| 66 | author: { |
| 67 | kind: message.author.kind, |
| 68 | id: message.author.id, |
| 69 | name: message.author.name, |
| 70 | display_name: message.author.display_name, |
| 71 | }, |
| 72 | body: message.deleted_at ? "" : message.body, |
| 73 | card, |
| 74 | created_at: message.created_at, |
| 75 | }; |
| 76 | } |
| 77 | |
| 78 | /** |
| 79 | * g1t's own chat, over the chat service. Replies stay in the thread they |
| 80 | * were asked in, and carry the delivery's hops, `asked_by` and `asker` on, |
| 81 | * so an agent the reply @mentions is woken one hop further along the same |
| 82 | * person's request, with that person's access. |
| 83 | */ |
| 84 | export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface { |
| 85 | const client = chatClient(chat); |
| 86 | const { workspace, channel_id: channel, agent_id: agent } = delivery; |
| 87 | // Whether 👀 went on, so `settle` knows what to take back. |
| 88 | let acknowledged: Promise<boolean> = Promise.resolve(false); |
| 89 | return { |
| 90 | async history(limit) { |
| 91 | const page = await client.historyForAgent(workspace, channel, agent, { thread_root: delivery.thread_root, limit }); |
| 92 | if (!page.ok) throw new Error(`reading the conversation failed: ${page.error.message}`); |
| 93 | return page.value.filter((message) => !message.deleted_at).map(fromChat); |
| 94 | }, |
| 95 | async typing() { |
| 96 | await client.agentTyping(workspace, channel, agent).catch(() => undefined); |
| 97 | }, |
| 98 | async acknowledge() { |
| 99 | acknowledged = (async () => { |
| 100 | try { |
| 101 | // A first message's hello answers nothing; a DM's people decide the rest. |
| 102 | if (delivery.message_id.startsWith("hello:")) return false; |
| 103 | if (delivery.channel_kind === "dm") { |
| 104 | const audience = await client.audience(workspace, channel); |
| 105 | if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false; |
| 106 | } |
| 107 | const reacted = await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN); |
| 108 | return reacted.ok; |
| 109 | } catch { |
| 110 | return false; |
| 111 | } |
| 112 | })(); |
| 113 | await acknowledged; |
| 114 | }, |
| 115 | async settle(outcome) { |
| 116 | try { |
| 117 | if (!(await acknowledged)) return; |
| 118 | await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN, true); |
| 119 | if (outcome === "done") await client.reactAsAgent(workspace, channel, agent, delivery.message_id, DONE); |
| 120 | } catch { |
| 121 | // A reaction is a courtesy: the reply stands without it. |
| 122 | } |
| 123 | }, |
| 124 | async post(body, card = null) { |
| 125 | const posted = await client.postAsAgent(workspace, channel, agent, { |
| 126 | body, |
| 127 | card, |
| 128 | thread_root: delivery.thread_root, |
| 129 | hops: Math.min(delivery.hops, CHAT_MAX_HOPS), |
| 130 | asked_by: delivery.asked_by, |
| 131 | // Agents this reply wakes act for the same person, with their access. |
| 132 | asker: delivery.asker ?? null, |
| 133 | // Chat adds this agent, and never hands the post back to the one that sent it the work. |
| 134 | chain: delivery.chain ?? [], |
| 135 | }); |
| 136 | if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`); |
| 137 | return posted.value.id; |
| 138 | }, |
| 139 | }; |
| 140 | } |
| 141 | |
| 142 | /** The surface a delivery came from. Every delivery is g1t's today. */ |
| 143 | export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface { |
| 144 | switch (delivery.surface ?? "g1t") { |
| 145 | case "g1t": |
| 146 | default: |
| 147 | return g1tSurface(chat, delivery); |
| 148 | } |
| 149 | } |