Skip to content
149 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 (docs/WORKSPACE.md,
7 * "Working from another chat app"). Only g1t's adapter exists.
8 */
9import 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.
12import { CHAT_MAX_HOPS, chatClient } from "../../../packages/contracts/src/chat.ts";
13
14/** One message, as the reply loop reads it, whatever the surface. */
15export 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
24export 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
47export const SEEN = "👀";
48export 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 */
55export 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. */
60export 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 */
84export 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. */
143export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface {
144 switch (delivery.surface ?? "g1t") {
145 case "g1t":
146 default:
147 return g1tSurface(chat, delivery);
148 }
149}