Skip to content
258 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 * How often an agent still working says so again. Chat shows each call
108 * for `AGENT_TYPING_MS` (8 s, services/chat/src/messages.ts), so this stays
109 * under it and the indicator never flickers off between beats.
110 */
111export const TYPING_HEARTBEAT_MS = 5_000;
112
113/**
114 * Keeps the agent shown as typing while it works: `typing()` now and every
115 * `everyMs` until the function it returns is called. A reply that reads
116 * code, consults a colleague or starts a session takes a minute or more,
117 * and one call alone would fade after seconds while the agent is still on
118 * it. Never throws: a failed beat is simply the next one's turn.
119 */
120export function keepTyping(surface: Pick<Surface, "typing">, everyMs = TYPING_HEARTBEAT_MS): () => void {
121 let on = true;
122 const beat = () => {
123 if (!on) return;
124 try {
125 void surface.typing().catch(() => undefined);
126 } catch {
127 // A courtesy only.
128 }
129 };
130 beat();
131 const timer = setInterval(beat, everyMs);
132 return () => {
133 on = false;
134 clearInterval(timer);
135 };
136}
137
138/**
139 * Whether an agent reacts in a conversation: in channels and in direct
140 * messages with more than one person. In a DM with one person, the typing
141 * indicator says enough.
142 */
143export function reactsIn(kind: "channel" | "dm", people: number): boolean {
144 return kind === "channel" || people > 1;
145}
146
147/** A g1t chat message as the loop reads it. */
148export function fromChat(message: ChatMessage): SurfaceMessage {
149 const card = message.card
150 ? [message.card.kind, message.card.title, message.card.detail, message.card.state].filter(Boolean).join(" · ")
151 : null;
152 return {
153 id: message.id,
154 author: {
155 kind: message.author.kind,
156 id: message.author.id,
157 name: message.author.name,
158 display_name: message.author.display_name,
159 },
160 body: message.deleted_at ? "" : message.body,
161 card,
162 created_at: message.created_at,
163 };
164}
165
166/**
167 * g1t's own chat, over the chat service. Replies stay in the thread they
168 * were asked in, and carry the delivery's hops, `asked_by` and `asker` on.
169 * A reply wakes nobody, @mentions or not; a hand-off wakes its colleague
170 * one hop further along the same person's request, with that person's
171 * access.
172 */
173export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface {
174 const client = chatClient(chat);
175 const { workspace, channel_id: channel, agent_id: agent } = delivery;
176 // Whether 👀 went on, so `settle` knows what to take back.
177 let acknowledged: Promise<boolean> = Promise.resolve(false);
178 return {
179 async history(limit) {
180 const page = await client.historyForAgent(workspace, channel, agent, { thread_root: delivery.thread_root, limit });
181 if (!page.ok) throw new Error(`reading the conversation failed: ${page.error.message}`);
182 return page.value.filter((message) => !message.deleted_at).map(fromChat);
183 },
184 async typing() {
185 await client.agentTyping(workspace, channel, agent).catch(() => undefined);
186 },
187 async acknowledge() {
188 acknowledged = (async () => {
189 try {
190 // A DM's people decide.
191 if (delivery.channel_kind === "dm") {
192 const audience = await client.audience(workspace, channel);
193 if (!audience.ok || !reactsIn("dm", audience.value.member_count)) return false;
194 }
195 const reacted = await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN);
196 return reacted.ok;
197 } catch {
198 return false;
199 }
200 })();
201 await acknowledged;
202 },
203 async settle(outcome) {
204 try {
205 if (!(await acknowledged)) return;
206 await client.reactAsAgent(workspace, channel, agent, delivery.message_id, SEEN, true);
207 if (outcome === "done") await client.reactAsAgent(workspace, channel, agent, delivery.message_id, DONE);
208 } catch {
209 // A reaction is a courtesy: the reply stands without it.
210 }
211 },
212 async post(body, card = null) {
213 const posted = await client.postAsAgent(workspace, channel, agent, {
214 body,
215 card,
216 thread_root: delivery.thread_root,
217 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
218 asked_by: delivery.asked_by,
219 // Agents this reply wakes act for the same person, with their access.
220 asker: delivery.asker ?? null,
221 // Chat adds this agent, and never hands the post back to the one that sent it the work.
222 chain: delivery.chain ?? [],
223 });
224 if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`);
225 return posted.value.id;
226 },
227 async conversation() {
228 try {
229 const found = await client.conversationForAgent(workspace, channel, agent, delivery.asked_by);
230 return found.ok ? conversationFrom(found.value) : null;
231 } catch {
232 return null;
233 }
234 },
235 async handOff(colleagueId, brief) {
236 const done = await client.handOffAsAgent(workspace, channel, agent, {
237 colleague_id: colleagueId,
238 brief,
239 thread_root: delivery.thread_root,
240 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
241 asked_by: delivery.asked_by,
242 // The colleague works for the same person, with their access.
243 asker: delivery.asker ?? null,
244 chain: delivery.chain ?? [],
245 });
246 return done.ok ? { ok: true, where: done.value.where, opened: done.value.opened } : { ok: false, message: done.error.message };
247 },
248 };
249}
250
251/** The surface a delivery came from. Every delivery is g1t's today. */
252export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface {
253 switch (delivery.surface ?? "g1t") {
254 case "g1t":
255 default:
256 return g1tSurface(chat, delivery);
257 }
258}