Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Chat and workspace agents: channels, DMs and named agents you talk to | 1 | import assert from "node:assert/strict"; |
| 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. | 2 | import { mock, test } from "node:test"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 3 | |
| 4 | import type { AgentDelivery } from "@g1t/contracts"; | |
| 5 | ||
| 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. | 6 | import { TYPING_HEARTBEAT_MS, g1tSurface, keepTyping } from "./surface.ts"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 7 | |
| 8 | const delivery: AgentDelivery = { | |
| 9 | workspace: "acme", | |
| 10 | workspace_id: "wsp_1", | |
| 11 | channel_id: "chn_1", | |
| 12 | channel_kind: "channel", | |
| 13 | channel_name: "releases", | |
| 14 | agent_id: "agt_ship", | |
| 15 | message_id: "msg_1", | |
| 16 | thread_root: "msg_0", | |
| 17 | asked_by: "usr_dana", | |
| 18 | hops: 2, | |
| 19 | asker: { username: "dana", role: "member", can_write: false }, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 20 | chain: ["agt_g1t"], |
| Chat and workspace agents: channels, DMs and named agents you talk to | 21 | surface: "g1t", |
| 22 | }; | |
| 23 | ||
| 24 | /** A chat binding that records each call and answers `answers[method]`. */ | |
| 25 | function fakeChat(answers: Record<string, unknown>) { | |
| 26 | const calls: { method: string; body: any }[] = []; | |
| 27 | const binding = { | |
| 28 | async fetch(url: string, init: { body: string }) { | |
| 29 | const method = new URL(url).pathname.replace("/rpc/", ""); | |
| 30 | calls.push({ method, body: JSON.parse(init.body) }); | |
| 31 | return Response.json(answers[method] ?? { ok: true, value: null }); | |
| 32 | }, | |
| 33 | }; | |
| 34 | return { binding: binding as any, calls }; | |
| 35 | } | |
| 36 | ||
| 37 | test("the g1t adapter posts in the thread, handing on hops, asked_by and the asker's access", async () => { | |
| 38 | const chat = fakeChat({ post_as_agent: { ok: true, value: { id: "msg_2" } } }); | |
| 39 | const id = await g1tSurface(chat.binding, delivery).post("Tagged 1.4."); | |
| 40 | assert.equal(id, "msg_2"); | |
| 41 | const [call] = chat.calls; | |
| 42 | assert.equal(call.method, "post_as_agent"); | |
| 43 | assert.equal(call.body.agent_id, "agt_ship"); | |
| 44 | assert.equal(call.body.channel_id, "chn_1"); | |
| 45 | assert.deepEqual(call.body.message, { | |
| 46 | body: "Tagged 1.4.", | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 47 | card: null, |
| Chat and workspace agents: channels, DMs and named agents you talk to | 48 | thread_root: "msg_0", |
| 49 | hops: 2, | |
| 50 | asked_by: "usr_dana", | |
| 51 | asker: { username: "dana", role: "member", can_write: false }, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 52 | chain: ["agt_g1t"], |
| Chat and workspace agents: channels, DMs and named agents you talk to | 53 | }); |
| 54 | }); | |
| 55 | ||
| 56 | test("the g1t adapter reads the thread as the agent, and leaves out deleted messages", async () => { | |
| 57 | const message = (id: string, deleted: string | null) => ({ | |
| 58 | id, | |
| 59 | channel_id: "chn_1", | |
| 60 | author: { kind: "user", id: "usr_dana", name: "dana", display_name: "Dana", avatar: null, role: null }, | |
| 61 | kind: "text", | |
| 62 | body: "hi", | |
| 63 | card: null, | |
| 64 | thread_root: "msg_0", | |
| 65 | reply_count: 0, | |
| 66 | last_reply_at: null, | |
| 67 | created_at: "2026-10-08T12:00:00Z", | |
| 68 | edited_at: null, | |
| 69 | deleted_at: deleted, | |
| 70 | }); | |
| 71 | const chat = fakeChat({ history_for_agent: { ok: true, value: [message("msg_0", null), message("msg_1", "2026-10-08T12:01:00Z")] } }); | |
| 72 | const history = await g1tSurface(chat.binding, delivery).history(30); | |
| 73 | assert.deepEqual(chat.calls[0].body, { workspace: "acme", channel_id: "chn_1", agent_id: "agt_ship", thread_root: "msg_0", limit: 30 }); | |
| 74 | assert.deepEqual(history.map((m) => m.id), ["msg_0"]); | |
| 75 | assert.equal(history[0].author.name, "dana"); | |
| 76 | }); | |
| 77 | ||
| 78 | test("a failed post is an error the reply loop records; typing never throws", async () => { | |
| 79 | const chat = fakeChat({ post_as_agent: { ok: false, error: { code: "forbidden", message: "not a member" } } }); | |
| 80 | await assert.rejects(g1tSurface(chat.binding, delivery).post("x"), /not a member/); | |
| 81 | const broken = { fetch: async () => new Response("down", { status: 500 }) } as any; | |
| 82 | await g1tSurface(broken, delivery).typing(); | |
| 83 | }); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 84 | |
| 85 | test("reactions: in channels and group DMs, never a one-person DM", async () => { | |
| 86 | const { reactsIn } = await import("./surface.ts"); | |
| 87 | assert.equal(reactsIn("channel", 1), true); | |
| 88 | assert.equal(reactsIn("dm", 2), true); | |
| 89 | assert.equal(reactsIn("dm", 1), false); | |
| 90 | }); | |
| 91 | ||
| 92 | test("👀 when the desk picks it up, then ✅ when it answered", async () => { | |
| 93 | const chat = fakeChat({ react_as_agent: { ok: true, value: [] } }); | |
| 94 | const surface = g1tSurface(chat.binding, delivery); | |
| 95 | await surface.acknowledge(); | |
| 96 | await surface.settle("done"); | |
| 97 | const reacts = chat.calls.filter((c) => c.method === "react_as_agent").map((c) => [c.body.emoji, c.body.remove, c.body.message_id]); | |
| 98 | assert.deepEqual(reacts, [ | |
| 99 | ["👀", false, "msg_1"], | |
| 100 | ["👀", true, "msg_1"], | |
| 101 | ["✅", false, "msg_1"], | |
| 102 | ]); | |
| 103 | }); | |
| 104 | ||
| 105 | test("👀 is taken back after a notice or an apology", async () => { | |
| 106 | const chat = fakeChat({ react_as_agent: { ok: true, value: [] } }); | |
| 107 | const surface = g1tSurface(chat.binding, delivery); | |
| 108 | await surface.acknowledge(); | |
| 109 | await surface.settle("withdrawn"); | |
| 110 | assert.deepEqual(chat.calls.map((c) => [c.body.emoji, c.body.remove]), [["👀", false], ["👀", true]]); | |
| 111 | }); | |
| 112 | ||
| 113 | test("a one-person DM gets no reactions; a group DM does", async () => { | |
| 114 | const dm = { ...delivery, channel_kind: "dm" as const, channel_name: null }; | |
| 115 | const alone = fakeChat({ audience: { ok: true, value: { kind: "dm", member_user_ids: ["usr_dana"], member_count: 1 } } }); | |
| 116 | const quiet = g1tSurface(alone.binding, dm); | |
| 117 | await quiet.acknowledge(); | |
| 118 | await quiet.settle("done"); | |
| 119 | assert.deepEqual(alone.calls.map((c) => c.method), ["audience"]); | |
| 120 | const group = fakeChat({ audience: { ok: true, value: { kind: "dm", member_user_ids: ["usr_dana", "usr_bo"], member_count: 2 } }, react_as_agent: { ok: true, value: [] } }); | |
| 121 | const loud = g1tSurface(group.binding, dm); | |
| 122 | await loud.acknowledge(); | |
| 123 | assert.deepEqual(group.calls.map((c) => c.method), ["audience", "react_as_agent"]); | |
| 124 | }); | |
| 125 | ||
| Merge branch 'worktree-agent-a1398e81ad1a64c5f' | 126 | const profile = (kind: "user" | "agent", id: string, name: string, display_name: string, title: string | null = null) => ({ kind, id, name, display_name, avatar: null, role: kind === "agent" ? "Does things" : null, title }); |
| 127 | const channel = (kind: "channel" | "dm", isPrivate: boolean, name: string | null) => ({ id: "chn_1", workspace_id: "wsp_1", kind, name, topic: null, private: isPrivate, created_by: { kind: "user", id: "usr_dana" }, created_at: "", archived_at: null, last_message_at: null }); | |
| 128 | ||
| 129 | test("the conversation: what kind it is and who is in it, asked as the agent with who asked", async () => { | |
| 130 | const dm = fakeChat({ | |
| 131 | conversation_for_agent: { ok: true, value: { channel: channel("dm", true, null), members: [profile("agent", "agt_ship", "ship", "Ship"), profile("user", "usr_dana", "dana", "Dana")], people: 1, agents: 1 } }, | |
| 132 | }); | |
| 133 | const one = await g1tSurface(dm.binding, delivery).conversation(); | |
| 134 | assert.deepEqual(dm.calls[0].body, { workspace: "acme", channel_id: "chn_1", agent_id: "agt_ship", asked_by: "usr_dana" }); | |
| 135 | assert.equal(one?.kind, "dm"); | |
| 136 | assert.deepEqual(one?.members.map((m) => [m.kind, m.name, m.title]), [["agent", "ship", "Does things"], ["user", "dana", null]]); | |
| 137 | const group = fakeChat({ conversation_for_agent: { ok: true, value: { channel: channel("dm", true, null), members: [], people: 1, agents: 2 } } }); | |
| 138 | assert.equal((await g1tSurface(group.binding, delivery).conversation())?.kind, "group_dm"); | |
| 139 | const open = fakeChat({ conversation_for_agent: { ok: true, value: { channel: channel("channel", false, "releases"), members: [], people: 300, agents: 1 } } }); | |
| 140 | assert.deepEqual(await g1tSurface(open.binding, delivery).conversation(), { kind: "public_channel", name: "releases", members: [], people: 300, agents: 1 }); | |
| 141 | const broken = { fetch: async () => new Response("down", { status: 500 }) } as any; | |
| 142 | assert.equal(await g1tSurface(broken, delivery).conversation(), null, "never throws"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a hand-off goes to chat with the delivery's chain, so the colleague works for the same person", async () => { | |
| 146 | const chat = fakeChat({ hand_off_as_agent: { ok: true, value: { where: "group_dm", channel_id: "chn_9", message_id: "msg_9", opened: true } } }); | |
| 147 | const done = await g1tSurface(chat.binding, delivery).handOff("agt_mike", "@mike draft the role brief."); | |
| 148 | assert.deepEqual(done, { ok: true, where: "group_dm", opened: true }); | |
| 149 | assert.equal(chat.calls[0].method, "hand_off_as_agent"); | |
| 150 | assert.deepEqual(chat.calls[0].body, { | |
| 151 | workspace: "acme", | |
| 152 | channel_id: "chn_1", | |
| 153 | agent_id: "agt_ship", | |
| 154 | hand_off: { | |
| 155 | colleague_id: "agt_mike", | |
| 156 | brief: "@mike draft the role brief.", | |
| 157 | thread_root: "msg_0", | |
| 158 | hops: 2, | |
| 159 | asked_by: "usr_dana", | |
| 160 | asker: { username: "dana", role: "member", can_write: false }, | |
| 161 | chain: ["agt_g1t"], | |
| 162 | }, | |
| 163 | }); | |
| 164 | const refused = fakeChat({ hand_off_as_agent: { ok: false, error: { code: "invalid", message: "too many times" } } }); | |
| 165 | assert.deepEqual(await g1tSurface(refused.binding, delivery).handOff("agt_mike", "x"), { ok: false, message: "too many times" }); | |
| 166 | }); | |
| 167 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 168 | test("reacting that fails never throws, and nothing is taken back that never went on", async () => { |
| 169 | const broken = { fetch: async () => new Response("down", { status: 500 }) } as any; | |
| 170 | const surface = g1tSurface(broken, delivery); | |
| 171 | await surface.acknowledge(); | |
| 172 | await surface.settle("done"); | |
| 173 | const refused = fakeChat({ react_as_agent: { ok: false, error: { code: "forbidden", message: "not a member" } } }); | |
| 174 | const s2 = g1tSurface(refused.binding, delivery); | |
| 175 | await s2.acknowledge(); | |
| 176 | await s2.settle("done"); | |
| 177 | assert.equal(refused.calls.length, 1, "no ✅ or removal after a refused 👀"); | |
| 178 | }); | |
| 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. | 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 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.