Skip to content
227 linesCodeBlameRaw
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. Only g1t's adapter exists.
7 */
8import type { AgentDelivery, ChatMessage, ConversationForAgent, MessageCard, ServiceBinding } from "@g1t/contracts";
9
10// By path, not the package: it imports only types, so the adapter is tested under Node.
11import { CHAT_MAX_HOPS, chatClient } from "../../../packages/contracts/src/chat.ts";
12
13/** One message, as the reply loop reads it, whatever the surface. */
14export type SurfaceMessage = {
15 id: string;
16 author: { kind: "user" | "agent"; id: string; name: string; display_name: string };
17 body: string;
18 /** A card's one-line summary, when the message is a card. */
19 card: string | null;
20 created_at: string;
21};
22
23/** One member of a conversation, as an agent is told about it. */
24export type ConversationMember = {
25 kind: "user" | "agent";
26 id: string;
27 /** What `@` mentions: a person's username, an agent's handle. */
28 name: string;
29 display_name: string;
30 /** An agent's title, or its role when it has none; null for a person. */
31 title: string | null;
32};
33
34/**
35 * Where an agent is answering and who is in it (docs.g1t.sh/guides/agents/,
36 * "Who is in the conversation"): only these members read what it says there.
37 */
38export type Conversation = {
39 kind: "dm" | "group_dm" | "private_channel" | "public_channel";
40 /** A channel's name; null for a direct message. */
41 name: string | null;
42 /** Every agent, then people up to a cap. */
43 members: ConversationMember[];
44 people: number;
45 agents: number;
46};
47
48/** What handing work to a colleague did. */
49export type HandedOff = { ok: true; where: "here" | "group_dm"; opened: boolean } | { ok: false; message: string };
50
51export interface Surface {
52 /** The latest `limit` messages of the conversation (the thread, or the DM or channel), oldest first. */
53 history(limit: number): Promise<SurfaceMessage[]>;
54 /** Shows the agent typing. Never throws: it is a courtesy. */
55 typing(): Promise<void>;
56 /**
57 * Posts as the agent, in the conversation it was asked in, optionally as
58 * a card (a consult, say). Returns the new message's id.
59 */
60 post(body: string, card?: MessageCard | null): Promise<string>;
61 /**
62 * Reacts 👀 to the message that woke the agent, so people see it is on
63 * it. Never throws, and skipped where it adds nothing (see `reactsIn`).
64 */
65 acknowledge(): Promise<void>;
66 /**
67 * Once the reply has posted: 👀 becomes ✅ when it answered (`done`), or
68 * is taken away when it posted a notice or an apology (`withdrawn`).
69 * Never throws.
70 */
71 settle(outcome: "done" | "withdrawn"): Promise<void>;
72 /** The conversation and who is in it; null when it can't be read. Never throws. */
73 conversation(): Promise<Conversation | null>;
74 /**
75 * Hands work to a colleague agent for the person who asked: posted here
76 * when the colleague is in this channel or group DM, otherwise in the
77 * group DM of that person, this agent and the colleague, with a card
78 * here saying where it went. Wakes the colleague and nobody else.
79 */
80 handOff(colleagueId: string, brief: string): Promise<HandedOff>;
81}
82
83/** A conversation as the chat service describes it, as the reply loop reads it. */
84export function conversationFrom(value: ConversationForAgent): Conversation {
85 const { channel } = value;
86 const kind =
87 channel.kind === "dm" ? (value.people + value.agents > 2 ? "group_dm" : "dm") : channel.private ? "private_channel" : "public_channel";
88 return {
89 kind,
90 name: channel.kind === "dm" ? null : channel.name,
91 members: value.members.map((m) => ({
92 kind: m.kind,
93 id: m.id,
94 name: m.name,
95 display_name: m.display_name,
96 title: m.kind === "agent" ? m.title || m.role || null : null,
97 })),
98 people: value.people,
99 agents: value.agents,
100 };
101}
102
103export const SEEN = "👀";
104export const DONE = "✅";
105
106/**
107 * Whether an agent reacts in a conversation: in channels and in direct
108 * messages with more than one person. In a DM with one person, the typing
109 * indicator says enough.
110 */
111export function reactsIn(kind: "channel" | "dm", people: number): boolean {
112 return kind === "channel" || people > 1;
113}
114
115/** A g1t chat message as the loop reads it. */
116export function fromChat(message: ChatMessage): SurfaceMessage {
117 const card = message.card
118 ? [message.card.kind, message.card.title, message.card.detail, message.card.state].filter(Boolean).join(" · ")
119 : null;
120 return {
121 id: message.id,
122 author: {
123 kind: message.author.kind,
124 id: message.author.id,
125 name: message.author.name,
126 display_name: message.author.display_name,
127 },
128 body: message.deleted_at ? "" : message.body,
129 card,
130 created_at: message.created_at,
131 };
132}
133
134/**
135 * g1t's own chat, over the chat service. Replies stay in the thread they
136 * were asked in, and carry the delivery's hops, `asked_by` and `asker` on.
137 * A reply wakes nobody, @mentions or not; a hand-off wakes its colleague
138 * one hop further along the same person's request, with that person's
139 * access.
140 */
141export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface {
142 const client = chatClient(chat);
143 const { workspace, channel_id: channel, agent_id: agent } = delivery;
144 // Whether 👀 went on, so `settle` knows what to take back.
145 let acknowledged: Promise<boolean> = Promise.resolve(false);
146 return {
147 async history(limit) {
148 const page = await client.historyForAgent(workspace, channel, agent, { thread_root: delivery.thread_root, limit });
149 if (!page.ok) throw new Error(`reading the conversation failed: ${page.error.message}`);
150 return page.value.filter((message) => !message.deleted_at).map(fromChat);
151 },
152 async typing() {
153 await client.agentTyping(workspace, channel, agent).catch(() => undefined);
154 },
155 async acknowledge() {
156 acknowledged = (async () => {
157 try {
158 // A first message's hello answers nothing; a DM's people decide the rest.
159 if (delivery.message_id.startsWith("hello:")) return false;
160 if (delivery.channel_kind === "dm") {
161 const audience = await client.audience(workspace, channel);
162 if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false;
163 }
164 const reacted = await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN);
165 return reacted.ok;
166 } catch {
167 return false;
168 }
169 })();
170 await acknowledged;
171 },
172 async settle(outcome) {
173 try {
174 if (!(await acknowledged)) return;
175 await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN, true);
176 if (outcome === "done") await client.reactAsAgent(workspace, channel, agent, delivery.message_id, DONE);
177 } catch {
178 // A reaction is a courtesy: the reply stands without it.
179 }
180 },
181 async post(body, card = null) {
182 const posted = await client.postAsAgent(workspace, channel, agent, {
183 body,
184 card,
185 thread_root: delivery.thread_root,
186 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
187 asked_by: delivery.asked_by,
188 // Agents this reply wakes act for the same person, with their access.
189 asker: delivery.asker ?? null,
190 // Chat adds this agent, and never hands the post back to the one that sent it the work.
191 chain: delivery.chain ?? [],
192 });
193 if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`);
194 return posted.value.id;
195 },
196 async conversation() {
197 try {
198 const found = await client.conversationForAgent(workspace, channel, agent, delivery.asked_by);
199 return found.ok ? conversationFrom(found.value) : null;
200 } catch {
201 return null;
202 }
203 },
204 async handOff(colleagueId, brief) {
205 const done = await client.handOffAsAgent(workspace, channel, agent, {
206 colleague_id: colleagueId,
207 brief,
208 thread_root: delivery.thread_root,
209 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
210 asked_by: delivery.asked_by,
211 // The colleague works for the same person, with their access.
212 asker: delivery.asker ?? null,
213 chain: delivery.chain ?? [],
214 });
215 return done.ok ? { ok: true, where: done.value.where, opened: done.value.opened } : { ok: false, message: done.error.message };
216 },
217 };
218}
219
220/** The surface a delivery came from. Every delivery is g1t's today. */
221export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface {
222 switch (delivery.surface ?? "g1t") {
223 case "g1t":
224 default:
225 return g1tSurface(chat, delivery);
226 }
227}