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.
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; | |
| 3 | ||
| 4 | import type { AgentTeam, PresenceEntry } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import { agentIdsOn, localTime, showing, teamBudgetBlock, teamsSection, whoToPage, type TeamsHere } from "./teammates.ts"; | |
| 7 | ||
| 8 | const NOW = new Date("2026-10-09T01:12:00Z"); | |
| 9 | ||
| 10 | const person = (username: string, extra: Partial<AgentTeam["people"][number]> = {}): AgentTeam["people"][number] => ({ | |
| 11 | user_id: `usr_${username}`, | |
| 12 | username, | |
| 13 | name: null, | |
| 14 | title: null, | |
| 15 | timezone: null, | |
| 16 | owns: [], | |
| 17 | manager: null, | |
| 18 | maintainer: false, | |
| 19 | ...extra, | |
| 20 | }); | |
| 21 | ||
| 22 | const entry = (username: string, presence: PresenceEntry["presence"], extra: Partial<PresenceEntry> = {}): PresenceEntry => ({ | |
| 23 | user_id: `usr_${username}`, | |
| 24 | username, | |
| 25 | presence, | |
| 26 | dnd_until: null, | |
| 27 | status: null, | |
| 28 | at: 1, | |
| 29 | ...extra, | |
| 30 | }); | |
| 31 | ||
| 32 | const sales: AgentTeam = { | |
| 33 | slug: "sales", | |
| 34 | name: "Sales", | |
| 35 | description: "Two people and one agent", | |
| 36 | lead: { kind: "user", username: "sofia", name: "Sofia Reyes", avatar: null }, | |
| 37 | channel: { id: "chn_1", name: "sales" }, | |
| 38 | budget_micros: 150_000_000, | |
| 39 | people: [ | |
| 40 | person("sofia", { name: "Sofia Reyes", title: "Head of Sales", owns: ["the forecast"], manager: "chase", maintainer: true, timezone: "America/Chicago" }), | |
| 41 | person("jordan", { name: "Jordan Lee", title: "Account Executive", owns: ["Halcyon", "Bluebird"], manager: "sofia" }), | |
| 42 | ], | |
| An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how. | 43 | agent_ids: ["agt_david"], |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 44 | }; |
| 45 | ||
| 46 | const here = (presence: PresenceEntry[]): TeamsHere => ({ | |
| 47 | teams: [sales], | |
| 48 | agents: [ | |
| An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how. | 49 | { id: "agt_david", handle: "david", display_name: "David", title: "CRM keeper" }, |
| 50 | { id: "agt_pax", handle: "pax", display_name: "Pax", title: "Billing" }, | |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 51 | ], |
| 52 | presence, | |
| 53 | }); | |
| 54 | ||
| An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how. | 55 | test("an agent is on the teams it was added to, and only those", () => { |
| 56 | assert.deepEqual(agentIdsOn(sales), ["agt_david"]); | |
| 57 | assert.deepEqual(agentIdsOn({ ...sales, agent_ids: ["agt_pax", "agt_pax"] }), ["agt_pax"]); | |
| 58 | assert.deepEqual(agentIdsOn({ ...sales, agent_ids: [] }), []); | |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 59 | }); |
| 60 | ||
| 61 | test("an agent is told who leads, who owns what and who is around", () => { | |
| 62 | const dnd = new Date(NOW.getTime() + 3 * 3600_000).toISOString(); | |
| 63 | const text = teamsSection("agt_david", here([entry("sofia", "active", { dnd_until: dnd }), entry("jordan", "active")]), NOW)!; | |
| 64 | assert.match(text, /^## Your teams/); | |
| 65 | assert.match(text, /### Sales/); | |
| 66 | assert.match(text, /Led by Sofia Reyes \(@sofia\)\./); | |
| 67 | assert.match(text, /Its channel is #sales\./); | |
| 68 | assert.match(text, /share a budget of \$150\.00 a month/); | |
| 69 | assert.match(text, /Sofia Reyes \(@sofia\); Head of Sales, the lead, a maintainer; owns the forecast; reports to @chase \(focusing in Do Not Disturb until 23:12 their time; it's 20:12 for them\)/); | |
| 70 | assert.match(text, /Jordan Lee \(@jordan\); Account Executive; owns Halcyon, Bluebird; reports to @sofia \(online\)/); | |
| 71 | assert.match(text, /- @david: you/); | |
| 72 | // Sofia is focusing, so Jordan is the one to page. | |
| 73 | assert.match(text, /When a person is needed: Jordan Lee \(@jordan\), who is online now\./); | |
| 74 | }); | |
| 75 | ||
| 76 | test("with no one reachable, the agent is told to post and wait", () => { | |
| 77 | const text = teamsSection("agt_david", here([entry("sofia", "away"), entry("jordan", "offline")]), NOW)!; | |
| 78 | assert.match(text, /No one on Sales can be reached now; post in #sales and say they'll see it when they're back\./); | |
| 79 | }); | |
| 80 | ||
| 81 | test("without presence, nobody is said to be offline", () => { | |
| 82 | const text = teamsSection("agt_david", here([]), NOW)!; | |
| 83 | assert.doesNotMatch(text, /offline/); | |
| 84 | assert.doesNotMatch(text, /When a person is needed/); | |
| 85 | }); | |
| 86 | ||
| 87 | test("an agent on no team is told nothing", () => { | |
| 88 | assert.equal(teamsSection("agt_david", { teams: [], agents: [], presence: [] }, NOW), null); | |
| 89 | assert.equal(teamsSection("agt_david", null, NOW), null); | |
| 90 | }); | |
| 91 | ||
| 92 | test("who to page: the lead, then a maintainer, then anyone reachable", () => { | |
| 93 | const presence = new Map([entry("sofia", "active"), entry("jordan", "active")].map((e) => [e.user_id, e])); | |
| 94 | assert.equal(whoToPage(sales, presence, NOW, true)?.username, "sofia"); | |
| 95 | presence.set("usr_sofia", entry("sofia", "away")); | |
| 96 | assert.equal(whoToPage(sales, presence, NOW, true)?.username, "jordan"); | |
| 97 | assert.equal(whoToPage(sales, presence, NOW, false), null); | |
| 98 | }); | |
| 99 | ||
| 100 | test("presence and local time read plainly", () => { | |
| 101 | assert.equal(showing(undefined, null, NOW, true), "offline"); | |
| 102 | assert.equal(showing(entry("a", "away", { status: { emoji: "🌴", text: "On leave", clear_at: null, source: "manual", set_at: "" } }), null, NOW, true), 'away; status "🌴 On leave"'); | |
| 103 | assert.equal(showing(entry("a", "active"), null, NOW, false), null); | |
| 104 | assert.equal(localTime("Europe/London", NOW), "02:12"); | |
| 105 | assert.equal(localTime("Not/AZone", NOW), null); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a team's budget stops its agents once they have spent it together", () => { | |
| 109 | assert.equal(teamBudgetBlock([{ name: "Sales", budget_micros: 150_000_000, spent_micros: 149_000_000 }]), null); | |
| 110 | assert.match(teamBudgetBlock([{ name: "Sales", budget_micros: 150_000_000, spent_micros: 150_000_000 }])!, /^Sales's agents have used the team's budget of \$150\.00/); | |
| 111 | }); |