Skip to content
92 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, 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 /** Posts as the agent, in the conversation it was asked in. Returns the new message's id. */
30 post(body: string): Promise<string>;
31}
32
33/** A g1t chat message as the loop reads it. */
34export function fromChat(message: ChatMessage): SurfaceMessage {
35 const card = message.card
36 ? [message.card.kind, message.card.title, message.card.detail, message.card.state].filter(Boolean).join(" · ")
37 : null;
38 return {
39 id: message.id,
40 author: {
41 kind: message.author.kind,
42 id: message.author.id,
43 name: message.author.name,
44 display_name: message.author.display_name,
45 },
46 body: message.deleted_at ? "" : message.body,
47 card,
48 created_at: message.created_at,
49 };
50}
51
52/**
53 * g1t's own chat, over the chat service. Replies stay in the thread they
54 * were asked in, and carry the delivery's hops, `asked_by` and `asker` on,
55 * so an agent the reply @mentions is woken one hop further along the same
56 * person's request, with that person's access.
57 */
58export function g1tSurface(chat: ServiceBinding, delivery: AgentDelivery): Surface {
59 const client = chatClient(chat);
60 const { workspace, channel_id: channel, agent_id: agent } = delivery;
61 return {
62 async history(limit) {
63 const page = await client.historyForAgent(workspace, channel, agent, { thread_root: delivery.thread_root, limit });
64 if (!page.ok) throw new Error(`reading the conversation failed: ${page.error.message}`);
65 return page.value.filter((message) => !message.deleted_at).map(fromChat);
66 },
67 async typing() {
68 await client.agentTyping(workspace, channel, agent).catch(() => undefined);
69 },
70 async post(body) {
71 const posted = await client.postAsAgent(workspace, channel, agent, {
72 body,
73 thread_root: delivery.thread_root,
74 hops: Math.min(delivery.hops, CHAT_MAX_HOPS),
75 asked_by: delivery.asked_by,
76 // Agents this reply wakes act for the same person, with their access.
77 asker: delivery.asker ?? null,
78 });
79 if (!posted.ok) throw new Error(`posting the reply failed: ${posted.error.message}`);
80 return posted.value.id;
81 },
82 };
83}
84
85/** The surface a delivery came from. Every delivery is g1t's today. */
86export function surfaceFor(chat: ServiceBinding, delivery: AgentDelivery): Surface {
87 switch (delivery.surface ?? "g1t") {
88 case "g1t":
89 default:
90 return g1tSurface(chat, delivery);
91 }
92}