Skip to content

Commit

In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.

syntaqxcommitted Parentfadde08Browse files
13 files+452−440/13 viewed
+12−4
544544 </Conversation>
545545
546546 In a group message with several agents, a message that mentions some of
547−them goes only to those; one that mentions none goes to all of them.
547+them goes only to those. One that mentions none goes to the agent you are
548+talking with: whichever agents answered you last, or, before any has, the
549+ones you last mentioned; your first message, before you have addressed
550+anyone, goes to all of them. Every agent there is told who you are
551+talking with, so one woken beside that agent, by a message that names
552+them both, answers only what is clearly for it.
548553
549554 In a workspace that has hired Sam from **Support Specialist** and Margo
550555 from **QA Engineer**:
583588
584589 - **DM it.** In a direct message, it answers every message you send. In a
585590 group message with several agents, mention the ones you want; a message
586− that mentions none of them goes to all of them.
591+ that mentions none of them goes to the agent you are talking with (the
592+ one that answered you last, or that you last mentioned), and to all of
593+ them only before you have addressed any.
587594 - **Mention it** in a channel it is a member of: `@margo …`. In a channel,
588595 an agent answers only when it is mentioned, and replies in the thread.
589596
592599 one person's message stops after six hops, so agents can't keep each other
593600 busy without a person.
594601
595−While it writes, the agent shows as typing. Its answer is charged to its
596−own budget; see [what an agent costs](#what-an-agent-costs).
602+While it works, the agent shows as typing, and as still working on it once
603+that has taken more than a few seconds. Its answer is charged to its own
604+budget; see [what an agent costs](#what-an-agent-costs).
597605
598606 <Aside type="note" title="What a reply can see">
599607 A reply reads the conversation it is in: the thread, or the latest 30
+15−5
198198 - The same group of members always gets the same conversation, whatever
199199 order you pick them in.
200200 - A DM with only yourself is a place for notes.
201−- **In a DM, every agent in it answers every message from a person.** You
201+- **In a DM with one agent, it answers every message from a person.** You
202202 don't need to mention it. That is the quickest way to talk to an agent:
203203 open a DM with it and say what you need.
204204 - **In a group message with several agents,** mention the ones you want
205− an answer from. A message that mentions none of them goes to all of them.
205+ an answer from. A message that mentions none of them goes to the agent
206+ you are talking with: whichever agents answered you last, or, if none
207+ has answered yet, the ones you last mentioned. So after "@mike make a
208+ PDF of that resume" and Mike's answer, "now make a fake one from that
209+ template" goes to Mike alone, and g1t stays out of it. Your first
210+ message, before you have addressed anyone, goes to all of them; mention
211+ another agent whenever you want to switch, or several to hear from each.
206212 - **An agent can open a group message for you** when it hands work to a
207213 colleague who isn't where you asked: you, it and the colleague. A card
208214 where you asked links to it. See
361367 </Aside>
362368
363369 While an agent works on a reply, the channel shows it as typing, the same
364−as a person. If it is out of budget, it says so in the thread instead of
365−answering, and tells you who can raise its limit.
370+as a person, for as long as it works: a reply that reads code, consults a
371+colleague or starts a session can take a minute or more, and after ten
372+seconds the line says the agent is still working on it. If it is out of
373+budget, it says so in the thread instead of answering, and tells you who
374+can raise its limit.
366375
367376 ### Session cards
368377
446455 channel the moment they happen, without reloading. You also see:
447456
448457 - **Typing.** Who is writing right now, people and agents, in the channel
449− or in a thread.
458+ or in a thread. An agent shows for as long as it works on its reply, and
459+ as *still working on it* once that has been a while.
450460 - **Channel changes.** A new name, topic, or archiving.
451461 - **Cards.** An agent's session card, or any other card, changing state.
452462 - **Read state.** Your sidebar shows unread counts per channel, and the
+43−21
6565 import { Button } from "../ui/button";
6666 import { Card } from "../ui/card";
6767 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
68−import { type Mentionable, type ShownMessage, agentsAmong, channelPath, dmPlaceholder, isMixed, mentionNames, mergeMessages, mixedNotice, shownHandle, shownName, timeline } from "../../lib/chat";
68+import {
69+ type Mentionable,
70+ type ShownMessage,
71+ type Typer,
72+ WORKING_AFTER_MS,
73+ agentsAmong,
74+ channelPath,
75+ dmPlaceholder,
76+ isMixed,
77+ mentionNames,
78+ mergeMessages,
79+ mixedNotice,
80+ shownHandle,
81+ shownName,
82+ timeline,
83+ typingLabel,
84+} from "../../lib/chat";
6985 import { codeAccessPath } from "../../lib/workspace-nav";
7086 import { conversationCache } from "./conversation-cache";
7187 import { atBottom, useKeepAtBottom } from "./stick";
129145 // The message the open thread is under, as its page says, for when it is
130146 // further back than the conversation has loaded (a long-running session's card).
131147 const [threadRoot, setThreadRoot] = useState<ShownMessage | null>(null);
132− const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number }>>(new Map());
148+ // Who is typing, until when, and since when (so an agent still at it after a while is said to be working).
149+ const [typing, setTyping] = useState<Map<string, { member: MemberProfile; until: number; since: number }>>(new Map());
133150 const [joined, setJoined] = useState(data.joined);
134151 const [members, setMembers] = useState<ChannelMember[]>(data.members);
135152 // The channel as it is now: renamed, its topic changed, archived, live.
339356 } else if (event.type === "typing") {
340357 if (event.channel_id !== data.channel.id) return;
341358 if (event.member.kind === "user" && event.member.id === me?.id) return;
342− setTyping((now) => new Map(now).set(`${event.member.kind}:${event.member.id}`, { member: event.member, until: new Date(event.until).getTime() }));
359+ setTyping((now) => {
360+ const key = `${event.member.kind}:${event.member.id}`;
361+ // An agent working on a long reply says it is typing again every few seconds: one stretch, from its first.
362+ return new Map(now).set(key, { member: event.member, until: new Date(event.until).getTime(), since: now.get(key)?.since ?? Date.now() });
363+ });
343364 } else if (event.type === "channel.updated") {
344365 // Renamed, a new topic, archived or back: everyone looking sees it at once.
345366 if (event.channel.id !== data.channel.id) return;
600621 }}
601622 />
602623 );
603− const typers = [...typing.values()].map((t) => t.member);
624+ const typers = [...typing.values()];
604625 const name = channel.name ?? "";
605626 const archived = channel.kind === "channel" && !!channel.archived_at;
606627 // A mixed direct message names each member and says which are agents.
698719 </div>
699720 <div className="shrink-0 px-2 pb-3 sm:px-4 sm:pb-4 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))] in-data-[keyboard=open]:pb-2">
700721 <div className="mx-auto max-w-[56rem]">
701− <TypingLine members={typers} />
722+ <TypingLine typers={typers} />
702723 {archived ? (
703724 <Card className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
704725 <p className="flex items-center gap-2 text-sm text-muted">
14901511 );
14911512 }
14921513
1493−/** "reviewer is typing…", for whoever is. */
1494−/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
1495−const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"];
1496−
1497−function agentDoing(id: string): string {
1498− let hash = 0;
1499− for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
1500− return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
1501−}
1502−
1503−function TypingLine({ members }: { members: MemberProfile[] }) {
1504− const names = members.map(shownName);
1505− const who = names.length === 1 ? names[0] : names.length === 2 ? `${names[0]} and ${names[1]}` : names.length > 2 ? "Several people" : "";
1514+/**
1515+ * "reviewer is typing…", for whoever is; "Mike is still working on it…"
1516+ * for an agent that has been at a reply a while (`typingLabel`). The line
1517+ * redraws itself the moment the first of them crosses that mark.
1518+ */
1519+function TypingLine({ typers }: { typers: Typer[] }) {
1520+ const [, redraw] = useState(0);
1521+ const now = Date.now();
1522+ useEffect(() => {
1523+ const waits = typers.map((t) => t.since + WORKING_AFTER_MS - now).filter((wait) => wait > 0);
1524+ if (!waits.length) return;
1525+ const timer = setTimeout(() => redraw((n) => n + 1), Math.min(...waits));
1526+ return () => clearTimeout(timer);
1527+ }, [typers, now]);
1528+ const label = typingLabel(typers, now);
15061529 return (
15071530 <div aria-live="polite" className="flex h-6 items-center gap-1.5 px-1 text-xs text-muted">
1508− {who && (
1531+ {label && (
15091532 <>
15101533 <span aria-hidden="true" className="flex gap-0.5">
15111534 {[0, 1, 2].map((i) => (
15131536 ))}
15141537 </span>
15151538 <span>
1516− <span className="font-medium text-fg-soft">{who}</span>{" "}
1517− {names.length === 1 ? (members[0]!.kind === "agent" ? agentDoing(members[0]!.id) : "is typing") : "are typing"}…
1539+ <span className="font-medium text-fg-soft">{label.who}</span> {label.doing}…
15181540 </span>
15191541 </>
15201542 )}
+20−0
2222 shownHandle,
2323 shownName,
2424 timeline,
25+ typingLabel,
2526 unreadTotals,
27+ WORKING_AFTER_MS,
28+ agentDoing,
2629 type ShownMessage,
2730 } from "./chat.ts";
2831
207210 assert.equal(names.get("reviewer"), "Reviewer");
208211 assert.equal(names.get("cy"), undefined);
209212 });
213+
214+test("the typing line: people type, an agent does what its phrase says, then is still working on it", () => {
215+ const now = 100_000;
216+ const mike = { kind: "agent" as const, id: "a2", name: "mike", display_name: "Mike", avatar: null, role: null };
217+ assert.equal(typingLabel([], now), null);
218+ assert.deepEqual(typingLabel([{ member: ana, since: now - 60_000 }], now), { who: "Ana", doing: "is typing" }, "a person is never 'working'");
219+ assert.deepEqual(typingLabel([{ member: bot, since: now - 2_000 }], now), { who: "Reviewer", doing: agentDoing("a1") });
220+ assert.match(agentDoing("a1"), /^is /);
221+ assert.deepEqual(typingLabel([{ member: bot, since: now - WORKING_AFTER_MS }], now), { who: "Reviewer", doing: "is still working on it" });
222+ // Mike and @g1t on one reply each: on it, then still working, as the user saw them vanish after eight seconds before.
223+ assert.deepEqual(typingLabel([{ member: mike, since: now - 3_000 }, { member: bot, since: now - 1_000 }], now), { who: "Mike and Reviewer", doing: "are on it" });
224+ assert.deepEqual(typingLabel([{ member: mike, since: now - 30_000 }, { member: bot, since: now - 1_000 }], now), { who: "Mike and Reviewer", doing: "are still working on it" });
225+ assert.deepEqual(typingLabel([{ member: ana, since: now }, { member: bot, since: now - 30_000 }], now), { who: "Ana and Reviewer", doing: "are typing" }, "people and agents together");
226+ const third = { ...ana, id: "u3", name: "bo", display_name: "Bo" };
227+ assert.deepEqual(typingLabel([{ member: ana, since: now }, { member: bot, since: now }, { member: third, since: now }], now), { who: "Several people", doing: "are typing" });
228+ assert.deepEqual(typingLabel([{ member: mike, since: now }, { member: bot, since: now }, { member: { ...mike, id: "a3", name: "sam", display_name: "Sam" }, since: now }], now), { who: "Several agents", doing: "are on it" });
229+});
+38−0
319319 return names;
320320 }
321321
322+/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
323+const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"];
324+
325+export function agentDoing(id: string): string {
326+ let hash = 0;
327+ for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
328+ return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
329+}
330+
331+/**
332+ * After this long shown as typing, an agent is said to be still working
333+ * instead: a reply that reads code, consults a colleague or starts a
334+ * session takes a minute or more, and it keeps saying so the whole time.
335+ */
336+export const WORKING_AFTER_MS = 10_000;
337+
338+/** Someone shown as typing, and since when (the first typing event for them, kept across the ones that follow). */
339+export type Typer = { member: Pick<MemberProfile, "kind" | "id" | "display_name" | "name"> & { display_username?: string | null }; since: number };
340+
341+/**
342+ * The typing line: who, and what they are doing. A person is typing. An
343+ * agent is doing what its phrase says, then, past `WORKING_AFTER_MS`,
344+ * still working on it; several agents are on it together, and a mix of
345+ * people and agents are typing. Null when nobody is.
346+ */
347+export function typingLabel(typers: readonly Typer[], now: number): { who: string; doing: string } | null {
348+ if (!typers.length) return null;
349+ const names = typers.map((t) => shownName(t.member));
350+ const agents = typers.every((t) => t.member.kind === "agent");
351+ const who = names.length === 1 ? names[0]! : names.length === 2 ? `${names[0]} and ${names[1]}` : agents ? "Several agents" : "Several people";
352+ const long = typers.some((t) => now - t.since >= WORKING_AFTER_MS);
353+ if (typers.length === 1) {
354+ const [one] = typers;
355+ return { who, doing: one!.member.kind === "agent" ? (long ? "is still working on it" : agentDoing(one!.member.id)) : "is typing" };
356+ }
357+ return { who, doing: agents ? (long ? "are still working on it" : "are on it") : "are typing" };
358+}
359+
322360 /** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */
323361 export function backoff(attempt: number, random: () => number = Math.random): number {
324362 const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
+37−1
44 import type { User } from "@g1t/contracts";
55
66 import { askerAccess } from "../../../packages/contracts/src/workspace-agents.ts";
7−import { audienceFor, systemPrompt, turns } from "./prompt.ts";
7+import { audienceFor, lastAddressed, systemPrompt, turns } from "./prompt.ts";
88 import type { SurfaceMessage } from "./surface.ts";
99
1010 const agent = {
216216 assert.match(handed, /## Handed to you\n\n@g1t \(an agent\) handed you this work for @dana/);
217217 assert.match(handed, /Don't hand it back to @g1t\./);
218218 });
219+
220+// ── Who the person is talking with, among several agents ─────────────────
221+
222+const chase = (body: string, id = "m") => message(id, "user", "usr_syntaqx", "syntaqx", body);
223+const mikeSays = (body = "Here it is.", id = "m") => message(id, "agent", "agt_mike", "mike", body);
224+const g1tSays = (body = "Done.", id = "m") => message(id, "agent", "agt_g1t", "g1t", body);
225+const groupDm = {
226+ kind: "group_dm" as const,
227+ name: null,
228+ people: 1,
229+ agents: 2,
230+ members: [member("agent", "g1t", "g1t"), member("agent", "mike", "Mike", "Technical Recruiter"), member("user", "syntaqx", "Chase Pierce")],
231+};
232+
233+test("who the person is talking with: the last exchange, else their last mention, else nobody yet", () => {
234+ // The conversation as it happened: Mike the recruiter and @g1t in one group message.
235+ const transcript = [chase("Are you able to generate PDFs now?"), mikeSays(), g1tSays(), chase("@Mike Make a PDF of that example resume for me"), mikeSays("Made it.")];
236+ assert.deepEqual(lastAddressed([...transcript, chase("Let's take that template and make a fake resume for some fake person")], groupDm, "usr_syntaqx"), ["mike"]);
237+ assert.deepEqual(lastAddressed(transcript.slice(0, 3), groupDm, "usr_syntaqx"), ["g1t", "mike"], "both answered the first question");
238+ assert.deepEqual(lastAddressed([chase("hi")], groupDm, "usr_syntaqx"), [], "nobody yet");
239+ assert.deepEqual(lastAddressed([chase("@g1t and @mike, compare notes"), chase("quickly")], groupDm, "usr_syntaqx"), ["g1t", "mike"], "mentioned, not yet answered");
240+ assert.deepEqual(lastAddressed([chase("ask @sam"), g1tSays()], groupDm, "usr_syntaqx"), ["g1t"], "a mention of someone not here decides nothing");
241+ assert.deepEqual(lastAddressed([message("x", "user", "usr_bo", "bo", "@mike hi"), chase("hello")], groupDm, "usr_syntaqx"), [], "only this person's mentions");
242+});
243+
244+test("with several agents here, the prompt says who the person is talking with, and what that means for an unaddressed message", () => {
245+ const prompt = (addressed: string[] | null) => systemPrompt({ ...g1t, channel: { kind: "dm", name: null }, canHandOff: true, conversation: groupDm, addressed });
246+ assert.match(prompt(["mike"]), /@syntaqx is talking with @mike, not you: a message that names no agent is theirs, so answer only what is clearly for you, briefly, and don't redo their work\./);
247+ assert.match(prompt(["g1t"]), /@syntaqx is talking with you: a message that names no agent is yours to answer\./);
248+ assert.match(prompt(["g1t", "mike"]), /@syntaqx is talking with you and @mike: a message that names no agent is yours to answer\./);
249+ assert.match(prompt([]), /@syntaqx hasn't addressed one yet\. Answer what fits your role/);
250+ assert.doesNotMatch(prompt(null), /Several agents are here/);
251+ // Alone with the person, there is nothing to say about it.
252+ const alone = systemPrompt({ ...g1t, channel: { kind: "dm", name: null }, addressed: ["g1t"], conversation: { ...groupDm, kind: "dm", agents: 1, members: groupDm.members.filter((m) => m.name !== "mike") } });
253+ assert.doesNotMatch(alone, /Several agents are here/);
254+});
+62−0
7272 recentSessions?: string | null;
7373 /** The conversation and everyone in it, said every turn; absent when it couldn't be read. */
7474 conversation?: Conversation | null;
75+ /**
76+ * In a conversation with other agents: the handles of the agents the
77+ * person is talking with (`lastAddressed`), empty when they haven't
78+ * addressed one yet. Absent when the agent is the only one here.
79+ */
80+ addressed?: string[] | null;
7581 /** Whether the agent can hand work to a colleague from here (the hand_off tool). */
7682 canHandOff?: boolean;
7783 /** When a colleague handed this work over: that agent's handle. */
214220 const more = conversation.people - shownPeople;
215221 if (more > 0) lines.push(`- and ${more} more ${more === 1 ? "person" : "people"}`);
216222 const open = conversation.kind === "public_channel";
223+ const addressed = input.addressed && conversation.agents > 1 ? addressedLine(input) : null;
217224 return [
218225 "",
219226 open
222229 ...lines,
223230 "",
224231 `- ${open ? "Only its members are told" : "Only these members read"} what you say here. Writing the name or @handle of anyone not listed reaches no one: they aren't told${open ? "" : " and can't see it"}.`,
232+ ...(addressed ? [addressed] : []),
225233 delegate,
226234 honest,
227235 ];
228236 }
229237
238+/**
239+ * With other agents here, who the person is talking with, so an agent woken
240+ * beside the one they addressed (a message naming both, say) knows whose
241+ * question it is. In a direct message the chat service already wakes only
242+ * the agent addressed (docs.g1t.sh/guides/chat/, "Direct messages").
243+ */
244+function addressedLine(input: PromptInput): string {
245+ const handles = input.addressed ?? [];
246+ const person = `@${input.asker.name}`;
247+ if (!handles.length) return `- Several agents are here and ${person} hasn't addressed one yet. Answer what fits your role, and leave what fits a colleague's role to them.`;
248+ const me = handles.some((handle) => handle.toLowerCase() === input.agent.handle.toLowerCase());
249+ const others = handles.filter((handle) => handle.toLowerCase() !== input.agent.handle.toLowerCase()).map((handle) => `@${handle}`);
250+ if (me) return `- Several agents are here. ${person} is talking with you${others.length ? ` and ${listOf(others)}` : ""}: a message that names no agent is yours to answer.`;
251+ return `- Several agents are here. ${person} is talking with ${listOf(others)}, not you: a message that names no agent is theirs, so answer only what is clearly for you, briefly, and don't redo their work.`;
252+}
253+
254+/**
255+ * The agents the person is talking with, by handle, from the conversation
256+ * so far: the same rule as the chat service's `addressedAgents`
257+ * (services/chat/src/delivery.ts), which decides who an unaddressed
258+ * message wakes. Walking back from the latest message, the agents whose
259+ * messages come before any person's are the last exchange; with none, the
260+ * person's own latest message that @mentions agents here names them;
261+ * otherwise nobody has been addressed yet (empty).
262+ */
263+export function lastAddressed(history: SurfaceMessage[], conversation: Pick<Conversation, "members">, personId: string): string[] {
264+ const agents = conversation.members.filter((m) => m.kind === "agent");
265+ const byId = new Map(agents.map((agent) => [agent.id, agent.name]));
266+ const byHandle = new Map(agents.map((agent) => [agent.name.toLowerCase(), agent.name]));
267+ const exchange: string[] = [];
268+ for (let i = history.length - 1; i >= 0; i--) {
269+ const { author, body } = history[i];
270+ if (author.kind === "agent") {
271+ const handle = byId.get(author.id);
272+ if (handle && !exchange.includes(handle)) exchange.push(handle);
273+ continue;
274+ }
275+ if (exchange.length) break;
276+ if (author.id !== personId) continue;
277+ const named = mentionedIn(body).map((handle) => byHandle.get(handle)).filter((found): found is string => !!found);
278+ if (named.length) return named;
279+ }
280+ return exchange;
281+}
282+
283+/** `@handle` after the start or a character no address or word has (the chat service's rule), lowercased, each once. */
284+const MENTION = /(^|[^a-z0-9_.@-])@([a-z0-9](?:[a-z0-9_-]{0,38}[a-z0-9_])?)/gi;
285+
286+function mentionedIn(body: string): string[] {
287+ const found = new Set<string>();
288+ for (const match of body.matchAll(MENTION)) found.add(match[2].toLowerCase());
289+ return [...found];
290+}
291+
230292 /** What the agent can read and do, said honestly: with tools, within the audience rules; without, only this conversation. */
231293 function readingRules(tools: { code: boolean } | null, session = false, computer = false): string[] {
232294 if (!tools) {
+17−5
2424 import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts";
2525 import type { Tokens } from "./budget.ts";
2626 import { handOffPort } from "./handoff.ts";
27−import { HISTORY_LIMIT, systemPrompt, turns } from "./prompt.ts";
27+import { HISTORY_LIMIT, lastAddressed, systemPrompt, turns } from "./prompt.ts";
2828 import { loadShelf, skillsSection, teamSlugs } from "./skills.ts";
2929 import { readVersion } from "./skill-library.ts";
3030 import { type Specialist, orchestratorInstructions, orchestratorTier, rosterLines } from "./orchestrator.ts";
3434 import { REPLY_TIER, allowedProviders, effortOf, effortPlan, replyModel } from "./routing.ts";
3535 import { type SessionEnv, type SessionRow, actionPorts, sessionRow, startSession, steer } from "./sessions.ts";
3636 import { type Row, definitionOf, periods, selectAgents, toAgent } from "./store.ts";
37−import { type SurfaceMessage, surfaceFor } from "./surface.ts";
37+import { type SurfaceMessage, keepTyping, surfaceFor } from "./surface.ts";
3838 import { Audience } from "./audience.ts";
3939 import { audiencePorts, loadTeams, teamsOfAgents, toolPorts } from "./ports.ts";
4040 import { type ToolCall, type ToolPorts, ToolBox } from "./tools.ts";
273273 // What the tools did, for the audit table, and who the audience was.
274274 let toolCalls: ToolCall[] = [];
275275 let audienceHash: string | null = null;
276+ // Stops the typing heartbeat (surface.ts `keepTyping`), once the reply reads the conversation.
277+ let stopTyping: () => void = () => undefined;
276278
277279 const finish = async (outcome: Outcome) => {
280+ stopTyping();
278281 const statements = [
279282 db
280283 .prepare(
338341 }
339342 }
340343
341− // Read the conversation while showing that the agent is on it.
344+ // Read the conversation while showing that the agent is on it, and keep
345+ // showing it until the answer is posted or the reply ends another way.
342346 // And who is here: said every turn, so the agent knows who reads it and who doesn't.
343347 // And its teams, from their pages: who leads, who owns what, who is around.
344− const [, history, conversationHere, teamsHere] = await Promise.all([
345− surface.typing(),
348+ stopTyping = keepTyping(surface);
349+ const [history, conversationHere, teamsHere] = await Promise.all([
346350 surface.history(HISTORY_LIMIT),
347351 surface.conversation(),
348352 loadTeams(env, slug, row.workspace_id, { id: row.id }).catch(() => null),
368372 const chain = delivery.chain ?? [];
369373 const sender = chain.length ? await db.prepare("SELECT handle FROM agents WHERE id = ?").bind(chain[chain.length - 1]).first<{ handle: string }>() : null;
370374 const asker = { name: askerName ?? "someone", display_name: author?.display_name ?? null, access: delivery.asker ?? null };
375+ // With other agents here: who the person is talking with (prompt.ts `lastAddressed`).
376+ const addressed = conversationHere && conversationHere.agents > 1 ? lastAddressed(history, conversationHere, delivery.asked_by) : null;
371377 let posted: string | null = null;
372378 let spinOffs = 0;
373379
515521 colleagues: row.builtin ? null : rosterLines(specialists),
516522 recentSessions: recent,
517523 conversation: conversationHere,
524+ addressed,
518525 teams: teamsSection(row.id, teamsHere, now),
519526 canHandOff: !!toolbox?.definitions().some((tool) => tool.name === "hand_off"),
520527 abilities: abilitiesText,
531538 // Post as soon as there is an answer; the bill is settled after.
532539 // It wakes nobody, @mentions or not: colleagues get work only through hand_off.
533540 if (answer.text) posted = await surface.post(answer.text);
541+ // Posted, or nothing to post: no longer typing.
542+ stopTyping();
534543 return {
535544 text: answer.text,
536545 tokens: addTokens(answer.tokens, consulted.tokens),
553562 await finish({ status: "failed", error: message, reply_id: posted, ...usage }).catch((failure: unknown) =>
554563 console.error("agents: a failed reply was not recorded", id, String(failure)),
555564 );
565+ } finally {
566+ // However it ended, the heartbeat ends with it.
567+ stopTyping();
556568 }
557569 }
+28−2
11 import assert from "node:assert/strict";
2−import { test } from "node:test";
2+import { mock, test } from "node:test";
33
44 import type { AgentDelivery } from "@g1t/contracts";
55
6−import { g1tSurface } from "./surface.ts";
6+import { TYPING_HEARTBEAT_MS, g1tSurface, keepTyping } from "./surface.ts";
77
88 const delivery: AgentDelivery = {
99 workspace: "acme",
176176 await s2.settle("done");
177177 assert.equal(refused.calls.length, 1, "no ✅ or removal after a refused 👀");
178178 });
179+
180+test("the typing heartbeat says so at once and every few seconds until stopped, and never throws", () => {
181+ mock.timers.enable({ apis: ["setInterval"] });
182+ try {
183+ let beats = 0;
184+ const stop = keepTyping({ typing: async () => void beats++ });
185+ assert.equal(beats, 1, "shown as typing the moment the reply starts");
186+ mock.timers.tick(TYPING_HEARTBEAT_MS * 3);
187+ assert.equal(beats, 4, "and again each heartbeat while it works");
188+ stop();
189+ mock.timers.tick(TYPING_HEARTBEAT_MS * 10);
190+ assert.equal(beats, 4, "nothing after the reply ends");
191+ // A surface that fails, rejecting or throwing, stops nothing and throws nothing.
192+ const rejecting = keepTyping({ typing: () => Promise.reject(new Error("down")) });
193+ const throwing = keepTyping({
194+ typing: () => {
195+ throw new Error("down");
196+ },
197+ });
198+ mock.timers.tick(TYPING_HEARTBEAT_MS);
199+ rejecting();
200+ throwing();
201+ } finally {
202+ mock.timers.reset();
203+ }
204+});
+32−0
104104 export const DONE = "✅";
105105
106106 /**
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+ */
111+export 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+ */
120+export 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+/**
107139 * Whether an agent reacts in a conversation: in channels and in direct
108140 * messages with more than one person. In a DM with one person, the typing
109141 * indicator says enough.
+50−2
11 import { test } from "node:test";
22 import assert from "node:assert/strict";
33
4−import { MAX_HOPS, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal } from "./delivery.ts";
4+import { MAX_HOPS, type RecentMessage, addressedAgents, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal } from "./delivery.ts";
55
66 const ship = { id: "a1", handle: "ship" };
77 const docs = { id: "a2", handle: "docs" };
88
9−test("a person's direct message wakes every agent in it, at hop 0", () => {
9+test("a person's direct message with nothing said yet wakes every agent in it, at hop 0", () => {
1010 assert.deepEqual(deliveries({ author: "user:u", hops: 3, channelKind: "dm", agents: [ship, docs], mentioned: [] }), [
1111 { agent_id: "a1", hops: 0 },
1212 { agent_id: "a2", hops: 0 },
1313 ]);
14+ assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents: [ship, docs], mentioned: [], recent: [] }), [
15+ { agent_id: "a1", hops: 0 },
16+ { agent_id: "a2", hops: 0 },
17+ ]);
18+ // With one agent there is nobody else it could be for.
19+ assert.deepEqual(deliveries({ author: "user:u", hops: 0, channelKind: "dm", agents: [ship], mentioned: [], recent: [{ author: "user:u", mentioned: ["docs"] }] }), [{ agent_id: "a1", hops: 0 }]);
20+});
21+
22+const mike = { id: "a_mike", handle: "mike" };
23+const g1t = { id: "a_g1t", handle: "g1t" };
24+const says = (author: string, ...mentioned: string[]): RecentMessage => ({ author, mentioned });
25+
26+test("in a group DM, an unaddressed message goes to the agent the person is talking with", () => {
27+ // The conversation as it happened: Chase, Mike the recruiter and @g1t in one group message.
28+ const transcript = [
29+ says("user:chase"), // "Are you able to generate PDFs now?"
30+ says("agent:a_mike"),
31+ says("agent:a_g1t"),
32+ says("user:chase", "mike"), // "@Mike Make a PDF of that example resume for me"
33+ says("agent:a_mike"),
34+ ];
35+ // "Let's take that template and make a fake resume for some fake person": Mike was helping.
36+ assert.deepEqual(deliveries({ author: "user:chase", hops: 0, channelKind: "dm", agents: [mike, g1t], mentioned: [], recent: transcript }), [{ agent_id: "a_mike", hops: 0 }]);
37+ // The first question, with nothing before it, went to both; so does a follow-up to both their answers.
38+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", []), [mike, g1t]);
39+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", transcript.slice(0, 3)), [mike, g1t]);
40+ // A mention still goes only to those named, whoever was talking.
41+ assert.deepEqual(deliveries({ author: "user:chase", hops: 0, channelKind: "dm", agents: [mike, g1t], mentioned: ["g1t"], recent: transcript }), [{ agent_id: "a_g1t", hops: 0 }]);
42+});
43+
44+test("who is addressed: the last exchange, else the person's last mention, else everyone", () => {
45+ // Mentioned but not yet answered: the mention stands, through the person's own follow-ups.
46+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "Mike"), says("user:chase")]), [mike]);
47+ // Answered by a colleague after the mention (a hand-off here, say): the colleague has the conversation now.
48+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "mike"), says("agent:a_mike", "g1t"), says("user:chase"), says("agent:a_g1t")]), [g1t]);
49+ // Both answered the last message: both are in the exchange, in the conversation's order.
50+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "g1t", "mike"), says("agent:a_g1t"), says("agent:a_mike")]), [mike, g1t]);
51+ // A thank-you in between changes nothing: the person's last mention still says who.
52+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "mike"), says("agent:a_mike"), says("user:chase"), says("user:chase")]), [mike]);
53+ // Another person's message ends the exchange without deciding it; only this person's mentions do.
54+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "mike"), says("agent:a_mike"), says("user:bo", "g1t")]), [mike]);
55+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:bo", "g1t"), says("agent:a_g1t")]), [g1t], "whoever answered last, whoever they answered");
56+ // A second message before anyone answers the first goes where the first did.
57+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:bo", "g1t"), says("agent:a_g1t"), says("user:chase")]), [g1t]);
58+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase"), says("agent:a_mike"), says("user:chase")]), [mike], "only Mike answered the first");
59+ // Mentions of agents that aren't here, and messages of agents that left, decide nothing.
60+ assert.deepEqual(addressedAgents([mike, g1t], "user:chase", [says("user:chase", "sam"), says("agent:a_gone")]), [mike, g1t]);
61+ assert.deepEqual(addressedAgents([mike], "user:chase", [says("user:chase", "g1t")]), [mike], "one agent: nothing to decide");
1462 });
1563
1664 test("in a group direct message, a person who mentions some of its agents wakes only those", () => {
+66−3
44 *
55 * The rules (docs.g1t.sh/guides/agents/, "Talk to an agent"):
66 * - A person's message in a channel wakes the agent members it @mentions.
7− * In a direct message it wakes the agents in it that it @mentions, or
8− * all of them when it mentions none. That starts a chain at hop 0.
7+ * In a direct message it wakes the agents in it that it @mentions; one
8+ * that mentions none wakes the only agent there, or, with several, the
9+ * agent the person is talking with (`addressedAgents`), or all of them
10+ * when the conversation doesn't say. That starts a chain at hop 0.
911 * - An agent's message wakes nobody, @mentions or not. An agent gets a
1012 * colleague working only by handing off (`handOffPlace`), which wakes
1113 * that one colleague, one hop further along the chain it was answering.
7476 };
7577 }
7678
79+/** One earlier message of the conversation, as much of it as addressing needs. */
80+export type RecentMessage = {
81+ /** Who wrote it: `user:<id>` or `agent:<id>`. */
82+ author: string;
83+ /** Handles it @mentions, lowercased. */
84+ mentioned: string[];
85+};
86+
87+/** How many earlier messages decide who an unaddressed message is for. */
88+export const ADDRESSING_HISTORY = 30;
89+
90+/**
91+ * In a direct message with several agents, which of them a message from
92+ * `author` that mentions none of them is for: the agent the person is
93+ * talking with, read from the conversation so far (`recent`, oldest
94+ * first, the message itself left out). Walking back from the latest
95+ * message:
96+ *
97+ * 1. The agents whose messages come before any person's are the last
98+ * exchange: whoever answered last. The message is for them.
99+ * 2. With no answer yet, the person's own latest message that @mentions
100+ * agents of this conversation says who they addressed: it is for those.
101+ * Their messages that mention none are passed over on the way.
102+ *
103+ * So "@mike make a PDF", Mike's answer, then "now make a fake one" is for
104+ * Mike alone, however many agents are in the conversation, and so is a
105+ * second message sent before Mike answers; after a hand-off to a
106+ * colleague here, the colleague's answer makes the next message theirs.
107+ * Other people's mentions decide nothing, and agents no longer in the
108+ * conversation count for nothing. When nothing in `recent` decides, every
109+ * agent gets it, as every agent got the first message.
110+ */
111+export function addressedAgents(agents: AgentMember[], author: string, recent: RecentMessage[]): AgentMember[] {
112+ if (agents.length < 2) return agents;
113+ const byId = new Map(agents.map((agent) => [`agent:${agent.id}`, agent]));
114+ const byHandle = new Map(agents.map((agent) => [agent.handle.toLowerCase(), agent]));
115+ const exchange: AgentMember[] = [];
116+ for (let i = recent.length - 1; i >= 0; i--) {
117+ const message = recent[i];
118+ const agent = byId.get(message.author);
119+ if (agent) {
120+ if (!exchange.includes(agent)) exchange.push(agent);
121+ continue;
122+ }
123+ if (!message.author.startsWith("user:")) continue;
124+ // A person's message: the agents that answered since are the exchange.
125+ if (exchange.length) break;
126+ if (message.author !== author) continue;
127+ const named = message.mentioned.map((handle) => byHandle.get(handle.toLowerCase())).filter((found): found is AgentMember => !!found);
128+ if (named.length) return agents.filter((agent) => named.includes(agent));
129+ }
130+ if (exchange.length) return agents.filter((agent) => exchange.includes(agent));
131+ return agents;
132+}
133+
77134 export function deliveries(input: {
78135 /** Who wrote it: `user:<id>` or `agent:<id>`. */
79136 author: string;
84141 agents: AgentMember[];
85142 /** Handles the message @mentions, lowercased. */
86143 mentioned: string[];
144+ /**
145+ * In a direct message with several agents: the conversation before this
146+ * message, oldest first (`addressedAgents`). Absent or empty, an
147+ * unaddressed message goes to every agent in it.
148+ */
149+ recent?: RecentMessage[];
87150 }): Wake[] {
88151 // Only a person's message wakes anyone; agents reach each other by hand-off.
89152 if (!input.author.startsWith("user:")) return [];
90153 const mentioned = new Set(input.mentioned.map((h) => h.toLowerCase()));
91154 const named = input.agents.filter((agent) => mentioned.has(agent.handle.toLowerCase()));
92− const woken = input.channelKind === "dm" && !named.length ? input.agents : named;
155+ const woken = input.channelKind === "dm" && !named.length ? addressedAgents(input.agents, input.author, input.recent ?? []) : named;
93156 return woken.map((agent) => ({ agent_id: agent.id, hops: 0 }));
94157 }
95158
+32−1
6767
6868 import { type ActivityRow, activitySpan, chatActivity } from "./activity.ts";
6969 import { audienceKind, isShared, likePattern, readableBy } from "./audience.ts";
70−import { MAX_HOPS, addsOrchestrator, chainFor, deliveries, delivery, handOffPlace, handOffRefusal, personalAgentRefusal, type Chain, type Wake } from "./delivery.ts";
7170 import {
71+ ADDRESSING_HISTORY,
72+ MAX_HOPS,
73+ addsOrchestrator,
74+ chainFor,
75+ deliveries,
76+ delivery,
77+ handOffPlace,
78+ handOffRefusal,
79+ personalAgentRefusal,
80+ type Chain,
81+ type RecentMessage,
82+ type Wake,
83+} from "./delivery.ts";
84+import {
7285 MAX_REACTIONS_PER_MESSAGE,
7386 emojiImage,
7487 emojiName,
12171230 channelKind: channel.kind,
12181231 agents: agents.map((agent) => ({ id: agent.id, handle: agent.handle })),
12191232 mentioned: handles,
1233+ // With several agents in a direct message, who the person is talking with decides an unaddressed message.
1234+ recent: channel.kind === "dm" && agents.length > 1 ? await this.recentBefore(row) : [],
12201235 });
12211236 await this.deliverTo(place, row, chain, wakes);
12221237 }
12231238
1239+ /**
1240+ * The conversation before `row`, oldest first, as addressing reads it
1241+ * (src/delivery.ts `addressedAgents`): the latest messages of its thread,
1242+ * or of the conversation's top level, who wrote each and whom it mentions.
1243+ */
1244+ private async recentBefore(row: Pick<MessageRow, "id" | "channel_id" | "thread_root">): Promise<RecentMessage[]> {
1245+ const rows = await this.db
1246+ .prepare(
1247+ `SELECT author, mentions FROM messages WHERE channel_id = ?1 AND id < ?2 AND deleted_at IS NULL
1248+ AND ${row.thread_root ? "(thread_root = ?3 OR id = ?3)" : "thread_root IS NULL"} ORDER BY id DESC LIMIT ${ADDRESSING_HISTORY}`,
1249+ )
1250+ .bind(...(row.thread_root ? [row.channel_id, row.id, row.thread_root] : [row.channel_id, row.id]))
1251+ .all<{ author: string; mentions: string | null }>();
1252+ return rows.results.reverse().map((found) => ({ author: found.author, mentioned: (found.mentions ?? "").split(" ").filter(Boolean) }));
1253+ }
1254+
12241255 /** Hands a message to these agents (src/delivery.ts `delivery`). */
12251256 private async deliverTo(place: Place, row: { id: string; thread_root: string | null }, chain: Chain<AskerAccess>, wakes: Wake[]): Promise<void> {
12261257 const { channel, workspace } = place;