| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import type { User } from "@g1t/contracts"; |
| 5 | |
| 6 | import { Audience, type AudienceInfo, type AudiencePorts, type RepoRef, WITHHELD } from "./audience.ts"; |
| 7 | import { type MemoryViewer, type RecallPlace, changeableBy, cleanFact, memorySection, recallable, scopeFor, visibleTo } from "./memory.ts"; |
| 8 | import { alertDue, checkPolicy, DEFAULT_POLICY, policyBlock } from "./policy.ts"; |
| 9 | import { checkRoutine, checkSchedule, describeSchedule, nextRun } from "./schedule.ts"; |
| 10 | import { type ActionPorts, type ToolPorts, MAX_SESSION_TOOL_CALLS, MAX_TOOL_CALLS, ToolBox } from "./tools.ts"; |
| 11 | import { dollars } from "./money.ts"; |
| 12 | |
| 13 | // ── Memory: where a fact is recalled, who sees it, who changes it ─────── |
| 14 | |
| 15 | const dmWithAnn: RecallPlace = { channel_id: "chn_dm_ann", kind: "dm", people: ["ann"] }; |
| 16 | const groupDm: RecallPlace = { channel_id: "chn_dm_group", kind: "dm", people: ["ann", "bob"] }; |
| 17 | const privateOps: RecallPlace = { channel_id: "chn_ops", kind: "private", people: ["ann", "bob"] }; |
| 18 | const publicGeneral: RecallPlace = { channel_id: "chn_general", kind: "public", people: ["ann"] }; |
| 19 | |
| 20 | test("a person's fact is recalled only in a direct message with that one person", () => { |
| 21 | const fact = { scope: "person", scope_ref: "ann" }; |
| 22 | assert.equal(recallable(fact, dmWithAnn), true); |
| 23 | assert.equal(recallable(fact, groupDm), false, "not with Bob there too"); |
| 24 | assert.equal(recallable(fact, privateOps), false); |
| 25 | assert.equal(recallable(fact, publicGeneral), false); |
| 26 | assert.equal(recallable(fact, { channel_id: "chn_dm_bob", kind: "dm", people: ["bob"] }), false); |
| 27 | }); |
| 28 | |
| 29 | test("a conversation's fact stays in it; a workspace fact goes anywhere", () => { |
| 30 | assert.equal(recallable({ scope: "channel", scope_ref: "chn_ops" }, privateOps), true); |
| 31 | assert.equal(recallable({ scope: "channel", scope_ref: "chn_ops" }, publicGeneral), false); |
| 32 | assert.equal(recallable({ scope: "workspace", scope_ref: "" }, dmWithAnn), true); |
| 33 | assert.equal(recallable({ scope: "made-up", scope_ref: "" }, dmWithAnn), false); |
| 34 | }); |
| 35 | |
| 36 | test("an agent remembers into the narrowest scope the conversation allows", () => { |
| 37 | assert.deepEqual(scopeFor(dmWithAnn, null), { scope: "person", ref: "ann" }); |
| 38 | assert.deepEqual(scopeFor(dmWithAnn, "workspace"), { scope: "person", ref: "ann" }, "a DM can't write for the workspace"); |
| 39 | assert.deepEqual(scopeFor(privateOps, "workspace"), { scope: "channel", ref: "chn_ops" }, "nor can a private channel"); |
| 40 | assert.deepEqual(scopeFor(groupDm, "person"), { scope: "channel", ref: "chn_dm_group" }); |
| 41 | assert.deepEqual(scopeFor(publicGeneral, "workspace"), { scope: "workspace", ref: "" }); |
| 42 | assert.deepEqual(scopeFor(publicGeneral, null), { scope: "channel", ref: "chn_general" }); |
| 43 | // After reading an artifact the whole workspace can't: the asker's alone, wherever it is. |
| 44 | assert.deepEqual(scopeFor(publicGeneral, "workspace", "ann"), { scope: "person", ref: "ann" }); |
| 45 | assert.deepEqual(scopeFor(privateOps, null, "ann"), { scope: "person", ref: "ann" }); |
| 46 | }); |
| 47 | |
| 48 | test("people see what could be recalled for them; owners don't read others' facts", () => { |
| 49 | const owner: MemoryViewer = { id: "olga", owner: true, inChannel: () => false }; |
| 50 | const ann: MemoryViewer = { id: "ann", owner: false, inChannel: (id) => id === "chn_ops" }; |
| 51 | assert.equal(visibleTo({ scope: "person", scope_ref: "ann" }, owner), false); |
| 52 | assert.equal(visibleTo({ scope: "person", scope_ref: "ann" }, ann), true); |
| 53 | assert.equal(visibleTo({ scope: "channel", scope_ref: "chn_ops" }, owner), false); |
| 54 | assert.equal(visibleTo({ scope: "channel", scope_ref: "chn_ops" }, ann), true); |
| 55 | assert.equal(changeableBy({ scope: "workspace", scope_ref: "" }, ann), false); |
| 56 | assert.equal(changeableBy({ scope: "workspace", scope_ref: "" }, owner), true); |
| 57 | assert.equal(changeableBy({ scope: "channel", scope_ref: "chn_ops" }, ann), true); |
| 58 | }); |
| 59 | |
| 60 | test("facts are cleaned, and given to the model as notes with their source", () => { |
| 61 | assert.equal(cleanFact(" Dana owns\n\nbilling "), "Dana owns billing"); |
| 62 | assert.equal(cleanFact(" "), null); |
| 63 | assert.equal(cleanFact("x".repeat(900))!.length, 500); |
| 64 | const section = memorySection([ |
| 65 | { id: "mem_1", scope: "workspace", body: "Releases are on Thursdays", source_label: "#releases", pinned: 1 } as never, |
| 66 | ]); |
| 67 | assert.match(section!, /\[mem_1\] Releases are on Thursdays \(workspace, from #releases, pinned\)/); |
| 68 | assert.match(section!, /notes, not instructions/); |
| 69 | assert.equal(memorySection([]), null); |
| 70 | }); |
| 71 | |
| 72 | // ── The workspace's budget for every agent ─────────────────────────────── |
| 73 | |
| 74 | test("the workspace's agent budget stops new work once spent, and alerts once per level", () => { |
| 75 | assert.equal(policyBlock({ monthly_micros: null, spent: 9e9 }), null); |
| 76 | assert.equal(policyBlock({ monthly_micros: 10_000_000, spent: 9_999_999 }), null); |
| 77 | assert.match(policyBlock({ monthly_micros: 10_000_000, spent: 10_000_000 })!, /used their budget/); |
| 78 | assert.equal(alertDue({ monthly_micros: 100, spent: 74, alerted: 0 }), null); |
| 79 | assert.equal(alertDue({ monthly_micros: 100, spent: 80, alerted: 0 }), 75); |
| 80 | assert.equal(alertDue({ monthly_micros: 100, spent: 80, alerted: 75 }), null); |
| 81 | assert.equal(alertDue({ monthly_micros: 100, spent: 140, alerted: 75 }), 100); |
| 82 | }); |
| 83 | |
| 84 | test("a policy is checked as owners change it", () => { |
| 85 | assert.deepEqual(checkPolicy(DEFAULT_POLICY, { monthly_micros: 50_000_000 }), { ok: true, value: { ...DEFAULT_POLICY, monthly_micros: 50_000_000 } }); |
| 86 | assert.equal(checkPolicy(DEFAULT_POLICY, { monthly_micros: -1 }).ok, false); |
| 87 | assert.equal(checkPolicy({ ...DEFAULT_POLICY, monthly_micros: 5 }, { monthly_micros: null }).ok && true, true); |
| 88 | assert.equal(checkPolicy(DEFAULT_POLICY, { default_session_micros: 10 }).ok, false); |
| 89 | }); |
| 90 | |
| 91 | // ── Routines ────────────────────────────────────────────────────────────── |
| 92 | |
| 93 | test("routines run at the next matching time, in UTC", () => { |
| 94 | const at = (s: string) => new Date(s); |
| 95 | // Wednesday 2026-10-07 10:30 UTC. |
| 96 | const now = at("2026-10-07T10:30:00Z"); |
| 97 | assert.equal(nextRun({ every: "hour", minute: 15, hour: 0, weekday: 0 }, now).toISOString(), "2026-10-07T11:15:00.000Z"); |
| 98 | assert.equal(nextRun({ every: "hour", minute: 45, hour: 0, weekday: 0 }, now).toISOString(), "2026-10-07T10:45:00.000Z"); |
| 99 | assert.equal(nextRun({ every: "day", minute: 0, hour: 9, weekday: 0 }, now).toISOString(), "2026-10-08T09:00:00.000Z"); |
| 100 | assert.equal(nextRun({ every: "day", minute: 0, hour: 12, weekday: 0 }, now).toISOString(), "2026-10-07T12:00:00.000Z"); |
| 101 | // Friday evening: the next weekday is Monday. |
| 102 | assert.equal(nextRun({ every: "weekday", minute: 0, hour: 9, weekday: 0 }, at("2026-10-09T18:00:00Z")).toISOString(), "2026-10-12T09:00:00.000Z"); |
| 103 | assert.equal(nextRun({ every: "week", minute: 30, hour: 8, weekday: 1 }, now).toISOString(), "2026-10-12T08:30:00.000Z"); |
| 104 | assert.equal(describeSchedule({ every: "weekday", minute: 5, hour: 9, weekday: 0 }), "Every weekday at 09:05 UTC"); |
| 105 | }); |
| 106 | |
| 107 | test("routines and schedules are checked", () => { |
| 108 | assert.equal(checkSchedule({ every: "fortnight" }).ok, false); |
| 109 | assert.equal(checkSchedule({ every: "day", hour: 24 }).ok, false); |
| 110 | assert.deepEqual(checkSchedule({ every: "day" }), { ok: true, value: { every: "day", minute: 0, hour: 9, weekday: 1 } }); |
| 111 | assert.equal(checkRoutine({ name: "", instructions: "Summarise support", schedule: { every: "day", minute: 0, hour: 9, weekday: 1 }, channel_id: "c" }).ok, false); |
| 112 | assert.equal(checkRoutine({ name: "Digest", instructions: "short", schedule: { every: "day", minute: 0, hour: 9, weekday: 1 }, channel_id: "c" }).ok, false); |
| 113 | assert.equal(checkRoutine({ name: "Digest", instructions: "Summarise this week's support themes", schedule: { every: "day", minute: 0, hour: 9, weekday: 1 }, channel_id: "c" }).ok, true); |
| 114 | }); |
| 115 | |
| 116 | test("money reads as people expect", () => { |
| 117 | assert.equal(dollars(140_000), "$0.14"); |
| 118 | assert.equal(dollars(2_000_000), "$2.00"); |
| 119 | assert.equal(dollars(3_000), "<$0.01"); |
| 120 | assert.equal(dollars(1_204_000_000), "$1,204"); |
| 121 | }); |
| 122 | |
| 123 | // ── Tools that act ──────────────────────────────────────────────────────── |
| 124 | |
| 125 | const WEB: RepoRef = { id: "rep_web", namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" }; |
| 126 | const readPorts = { |
| 127 | readFile: async () => null, |
| 128 | searchCode: async () => [], |
| 129 | listIssues: async () => [], |
| 130 | getIssue: async () => null, |
| 131 | getPull: async () => null, |
| 132 | recentPulls: async () => [], |
| 133 | searchMessages: async () => [], |
| 134 | readThread: async () => null, |
| 135 | roster: async () => "", |
| 136 | consult: async () => ({ ok: false as const, message: "no" }), |
| 137 | } satisfies ToolPorts; |
| 138 | |
| 139 | function audienceWorld(info: AudienceInfo, people: User[], reads: Record<string, string[]>): AudiencePorts { |
| 140 | return { |
| 141 | info: async () => info, |
| 142 | users: async (ids) => people.filter((u) => ids.includes(u.id)), |
| 143 | workspaceRepos: async (viewer) => (reads[viewer.id] ?? []).includes(WEB.id) ? [WEB] : [], |
| 144 | readable: async (ids, viewer) => (ids.includes(WEB.id) && (reads[viewer.id] ?? []).includes(WEB.id) ? [WEB] : []), |
| 145 | }; |
| 146 | } |
| 147 | |
| 148 | const member = (id: string, code = true): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member", code_access: code }] }) as User; |
| 149 | |
| 150 | function actions(log: string[]): ActionPorts { |
| 151 | return { |
| 152 | remember: async (body) => (log.push(`remember:${body}`), { ok: true, message: "ok" }), |
| 153 | forget: async () => ({ ok: true, message: "ok" }), |
| 154 | draftIssue: async (repo, input) => (log.push(`draft:${repo.name}:${input.title}`), { ok: true, message: "drafted" }), |
| 155 | startSession: async (title) => (log.push(`session:${title}`), { ok: true, message: "started" }), |
| 156 | postUpdate: async () => ({ ok: true, message: "posted" }), |
| 157 | useSubagent: async () => ({ ok: true, message: "on it" }), |
| 158 | bringIn: async () => ({ ok: true, message: "on it" }), |
| 159 | }; |
| 160 | } |
| 161 | |
| 162 | const ctx = { agentId: "agt_me", notConsult: ["me"], hops: 0, maxHops: 6 }; |
| 163 | |
| 164 | test("a reply can spin off a session and draft an issue; a session can't spin off", async () => { |
| 165 | const log: string[] = []; |
| 166 | const audience = await Audience.build("acme", "ann", audienceWorld({ kind: "dm", member_user_ids: ["ann"], member_count: 1 }, [member("ann")], { ann: [WEB.id] })); |
| 167 | const reply = new ToolBox(audience, readPorts, ctx, [], actions(log)); |
| 168 | const names = reply.definitions().map((t) => t.name); |
| 169 | assert.ok(names.includes("start_session") && names.includes("draft_issue") && names.includes("remember")); |
| 170 | assert.ok(!names.includes("bring_in") && !names.includes("use_subagent") && !names.includes("post_update")); |
| 171 | const drafted = await reply.run("draft_issue", { repo: "web", title: "CSV export times out", body: "Over 100k rows." }); |
| 172 | assert.equal(drafted.outcome, "allowed"); |
| 173 | assert.deepEqual(log, ["draft:web:CSV export times out"]); |
| 174 | // A repository the audience can't read is withheld, never named. |
| 175 | const hidden = await reply.run("draft_issue", { repo: "acme/secret", title: "x", body: "y" }); |
| 176 | assert.equal(hidden.text, WITHHELD); |
| 177 | |
| 178 | const session = new ToolBox(audience, readPorts, { ...ctx, session: true }, [], actions(log)); |
| 179 | const inSession = session.definitions().map((t) => t.name); |
| 180 | assert.ok(!inSession.includes("start_session")); |
| 181 | assert.ok(inSession.includes("bring_in") && inSession.includes("use_subagent") && inSession.includes("post_update")); |
| 182 | assert.equal((await session.run("start_session", { title: "x", goal: "y" })).outcome, "refused"); |
| 183 | assert.equal(reply.maxCalls, MAX_TOOL_CALLS); |
| 184 | assert.equal(session.maxCalls, MAX_SESSION_TOOL_CALLS); |
| 185 | }); |
| 186 | |
| 187 | test("hand_off: offered from chat, never in a session or at the hop limit; not to itself or its sender; two per reply", async () => { |
| 188 | const log: string[] = []; |
| 189 | const handing: ActionPorts = { ...actions(log), handOff: async (handle, brief) => (log.push(`hand_off:${handle}:${brief}`), { ok: true, message: "handed" }) }; |
| 190 | const audience = await Audience.build("acme", "ann", audienceWorld({ kind: "dm", member_user_ids: ["ann"], member_count: 1 }, [member("ann")], { ann: [WEB.id] })); |
| 191 | const reply = new ToolBox(audience, readPorts, { ...ctx, notConsult: ["me", "g1t"] }, [], handing); |
| 192 | const names = reply.definitions().map((t) => t.name); |
| 193 | assert.ok(names.includes("hand_off") && names.includes("ask_colleague")); |
| 194 | const descriptions = Object.fromEntries(reply.definitions().map((t) => [t.name, t.description])); |
| 195 | assert.match(descriptions.hand_off, /only way to get a colleague working: an @mention in your message wakes nobody/); |
| 196 | assert.match(descriptions.ask_colleague, /quick question, privately/); |
| 197 | assert.match(descriptions.ask_colleague, /To give them the work itself, use hand_off/); |
| 198 | assert.equal((await reply.run("hand_off", { handle: "@me", brief: "x" })).outcome, "refused", "not to itself"); |
| 199 | assert.equal((await reply.run("hand_off", { handle: "g1t", brief: "x" })).outcome, "refused", "not back to the one that sent it"); |
| 200 | assert.equal((await reply.run("hand_off", { handle: "mike", brief: "" })).outcome, "refused", "a brief is needed"); |
| 201 | assert.equal((await reply.run("hand_off", { handle: "@Mike", brief: "Draft the role brief." })).outcome, "allowed"); |
| 202 | assert.equal((await reply.run("hand_off", { handle: "dot", brief: "Plan it." })).outcome, "allowed"); |
| 203 | assert.equal((await reply.run("hand_off", { handle: "sam", brief: "Tell them." })).outcome, "refused", "two per reply"); |
| 204 | assert.deepEqual(log, ["hand_off:mike:Draft the role brief.", "hand_off:dot:Plan it."]); |
| 205 | // Not in a session (bring_in is), and not at the hop limit. |
| 206 | assert.ok(!new ToolBox(audience, readPorts, { ...ctx, session: true }, [], handing).definitions().some((t) => t.name === "hand_off")); |
| 207 | assert.ok(!new ToolBox(audience, readPorts, { ...ctx, hops: 6 }, [], handing).definitions().some((t) => t.name === "hand_off")); |
| 208 | // Without the port (an old chat), it isn't offered. |
| 209 | assert.ok(!new ToolBox(audience, readPorts, ctx, [], actions(log)).definitions().some((t) => t.name === "hand_off")); |
| 210 | }); |
| 211 | |
| 212 | test("nobody drafts issues for someone who can't read code, and no hand-offs at the hop limit", async () => { |
| 213 | const log: string[] = []; |
| 214 | const noCode = await Audience.build("acme", "cal", audienceWorld({ kind: "dm", member_user_ids: ["cal"], member_count: 1 }, [member("cal", false)], { cal: [WEB.id] })); |
| 215 | const box = new ToolBox(noCode, readPorts, ctx, [], actions(log)); |
| 216 | assert.ok(!box.definitions().some((t) => t.name === "draft_issue")); |
| 217 | assert.equal((await box.run("draft_issue", { repo: "web", title: "x", body: "y" })).outcome, "refused"); |
| 218 | assert.deepEqual(log, []); |
| 219 | const audience = await Audience.build("acme", "ann", audienceWorld({ kind: "dm", member_user_ids: ["ann"], member_count: 1 }, [member("ann")], { ann: [WEB.id] })); |
| 220 | const atLimit = new ToolBox(audience, readPorts, { ...ctx, session: true, hops: 6 }, [], actions(log)); |
| 221 | assert.ok(!atLimit.definitions().some((t) => t.name === "bring_in" || t.name === "use_subagent")); |
| 222 | // The agent can't bring itself in. |
| 223 | const session = new ToolBox(audience, readPorts, { ...ctx, session: true }, [], actions(log)); |
| 224 | assert.equal((await session.run("bring_in", { handle: "@me", brief: "help" })).outcome, "refused"); |
| 225 | }); |
| 226 | |
| 227 | test("updates are limited per step", async () => { |
| 228 | const audience = await Audience.build("acme", "ann", audienceWorld({ kind: "dm", member_user_ids: ["ann"], member_count: 1 }, [member("ann")], { ann: [WEB.id] })); |
| 229 | const session = new ToolBox(audience, readPorts, { ...ctx, session: true }, [], actions([])); |
| 230 | for (let i = 0; i < 3; i++) assert.equal((await session.run("post_update", { text: `step ${i}` })).outcome, "allowed"); |
| 231 | assert.equal((await session.run("post_update", { text: "again" })).outcome, "refused"); |
| 232 | }); |
| 233 | |
| 234 | // ── Routines that run when something happens ───────────────────────────── |
| 235 | |
| 236 | import { describeEvents, suggestRoutines } from "./suggest.ts"; |
| 237 | import { checkRoutine as checkEventRoutine } from "./schedule.ts"; |
| 238 | |
| 239 | test("Margo's responsibilities suggest the routines that bind to events", () => { |
| 240 | const margo = ["Reviewing pull requests for risk and test coverage", "Test plans for new features", "Chasing flaky checks"]; |
| 241 | const suggested = suggestRoutines(margo, []); |
| 242 | assert.deepEqual( |
| 243 | suggested.map((s) => [s.routine.name, s.routine.events]), |
| 244 | [ |
| 245 | ["Review pull requests", ["pull_ready"]], |
| 246 | ["Test plans for new work", ["issue_opened"]], |
| 247 | ["Chase failing checks", ["checks_failed"]], |
| 248 | ], |
| 249 | ); |
| 250 | assert.match(suggested[0].routine.instructions, /risk and test coverage/); |
| 251 | // What it already runs on isn't suggested again. |
| 252 | assert.deepEqual( |
| 253 | suggestRoutines(margo, [{ name: "PR reviews", events: ["pull_ready"] }]).map((s) => s.routine.name), |
| 254 | ["Test plans for new work", "Chase failing checks"], |
| 255 | ); |
| 256 | assert.deepEqual(suggestRoutines(["Keep the office plants alive"], []), []); |
| 257 | }); |
| 258 | |
| 259 | test("an event routine needs no schedule, but a routine needs one or the other", () => { |
| 260 | const base = { name: "Review", instructions: "Review pull requests for risk", channel_id: "chn_qa" }; |
| 261 | assert.equal(checkEventRoutine({ ...base, schedule: null, events: ["pull_ready"] }).ok, true); |
| 262 | assert.equal(checkEventRoutine({ ...base, schedule: null, events: [] }).ok, false); |
| 263 | assert.equal(checkEventRoutine({ ...base, schedule: null, events: ["pull_exploded" as never] }).ok, false); |
| 264 | assert.equal(checkEventRoutine({ ...base, schedule: null, events: ["pull_ready"], repos: ["not a repo"] }).ok, false); |
| 265 | const ok = checkEventRoutine({ ...base, schedule: null, events: ["pull_ready", "pull_ready"], repos: ["Acme/Web"] }); |
| 266 | assert.ok(ok.ok && ok.value.events.length === 1 && ok.value.repos[0] === "acme/web"); |
| 267 | assert.equal(describeEvents(["pull_ready", "checks_failed"]), "When a pull request is ready for review or checks fail on a pull request"); |
| 268 | }); |
| 269 | |
| 270 | test("agents comment and review on issues and pull requests only where the asker can read", async () => { |
| 271 | const log: string[] = []; |
| 272 | const ports = { |
| 273 | ...actions(log), |
| 274 | comment: async (repo: RepoRef, asker: User, number: number) => (log.push(`comment:${repo.name}#${number}:${asker.username}`), { ok: true, message: "ok" }), |
| 275 | review: async (repo: RepoRef, asker: User, number: number, verdict: string) => (log.push(`review:${repo.name}#${number}:${verdict}:${asker.username}`), { ok: true, message: "ok" }), |
| 276 | }; |
| 277 | const audience = await Audience.build("acme", "ann", audienceWorld({ kind: "dm", member_user_ids: ["ann"], member_count: 1 }, [member("ann")], { ann: [WEB.id] })); |
| 278 | const session = new ToolBox(audience, readPorts, { ...ctx, session: true }, [], ports); |
| 279 | assert.ok(session.definitions().some((t) => t.name === "review_pull")); |
| 280 | await session.run("review_pull", { repo: "web", number: 12, verdict: "approve", body: "" }); |
| 281 | await session.run("review_pull", { repo: "web", number: 12, verdict: "made-up", body: "Looks risky" }); |
| 282 | await session.run("comment", { repo: "web", number: 3, body: "Test plan: …" }); |
| 283 | assert.equal((await session.run("comment", { repo: "acme/secret", number: 3, body: "x" })).text, WITHHELD); |
| 284 | assert.deepEqual(log, ["review:web#12:approve:ann", "review:web#12:comment:ann", "comment:web#3:ann"]); |
| 285 | const noCode = await Audience.build("acme", "cal", audienceWorld({ kind: "dm", member_user_ids: ["cal"], member_count: 1 }, [member("cal", false)], { cal: [WEB.id] })); |
| 286 | assert.ok(!new ToolBox(noCode, readPorts, { ...ctx, session: true }, [], ports).definitions().some((t) => t.name === "comment" || t.name === "review_pull")); |
| 287 | }); |
| 288 | |
| 289 | /// ── Artifacts, for everyone ────────────────────────────────────────────── |
| 290 | |
| 291 | import type { FolioAgentRead, FolioRef } from "@g1t/contracts"; |
| 292 | |
| 293 | import { type FoliosPorts, folioReadText, folioRef, sourceLink } from "./tools.ts"; |
| 294 | |
| 295 | const FOL = "fol_01jabcdefghjkmnpqrstvwxyz0"; |
| 296 | const SECRET = "fol_01jabcdefghjkmnpqrstvwxyz1"; |
| 297 | const NEW = "fol_01jabcdefghjkmnpqrstvwxyz2"; |
| 298 | |
| 299 | const ref = (id: string, title: string): FolioRef => ({ id, kind: "doc", title, icon: null, slug: `x-${id}`, path: `/acme/-/artifacts/x-${id}` }); |
| 300 | |
| 301 | const agentRead = (id: string, title: string, content: string, audience_can_read: boolean): FolioAgentRead => ({ |
| 302 | folio: { ...ref(id, title), edited_at: "2026-10-01T10:00:00Z" }, |
| 303 | space: { id: "spc_general", slug: "general", name: "General", agent_mode: "suggest" }, |
| 304 | content, |
| 305 | blocks: [{ id: "b1", type: "heading", level: 1, markdown: `# ${title}` }], |
| 306 | can: { read: true, suggest: true, edit: false }, |
| 307 | audience_can_read, |
| 308 | }); |
| 309 | |
| 310 | /** An artifacts service with one readable doc, one secret one, and whatever is made (readable here unless `hidden` says so). */ |
| 311 | function folioWorld(log: string[], options: { hidden?: Set<string>; forbidden?: string } = {}): FoliosPorts { |
| 312 | const hidden = options.hidden ?? new Set<string>(); |
| 313 | return { |
| 314 | spaces: async () => [ |
| 315 | { id: "spc_general", slug: "general", name: "General", description: null, kind: "workspace", projects: [], can: { read: true, suggest: true, edit: false } }, |
| 316 | { id: "spc_eng", slug: "engineering", name: "Engineering", description: "How we build", kind: "team", projects: ["acme/web"], can: { read: true, suggest: true, edit: true } }, |
| 317 | ], |
| 318 | recall: async () => [], |
| 319 | search: async (_v, audience, input) => (log.push(`search:${input.query}:${JSON.stringify(audience)}:${input.space_id}:${input.kind}`), `- Refunds policy (/acme/-/artifacts/x-${FOL}, id ${FOL})`), |
| 320 | read: async (_v, _a, id) => { |
| 321 | if (id === FOL) return { ok: true, value: agentRead(FOL, "Refunds policy", "# Refunds policy\n\n30 days.", true) }; |
| 322 | if (id === SECRET) return { ok: true, value: agentRead(SECRET, "Salary bands", "Bands: 1, 2, 3", false) }; |
| 323 | if (id === NEW) return { ok: true, value: agentRead(NEW, "Made", "x", !hidden.has("new")) }; |
| 324 | return { ok: false, code: "not_found", message: "No such artifact." }; |
| 325 | }, |
| 326 | stale: async () => "No artifacts are marked possibly out of date.", |
| 327 | async create(_v, input) { |
| 328 | log.push(`create:${input.kind}:${JSON.stringify(input.where)}:${input.source?.href ?? ""}`); |
| 329 | const where = input.where; |
| 330 | if (options.forbidden && typeof where === "object" && "space_id" in where && where.space_id === options.forbidden) return { ok: false, code: "forbidden", message: "ann can't add there." }; |
| 331 | return { ok: true, value: ref(NEW, input.title) }; |
| 332 | }, |
| 333 | edit: async (_v, id, edit) => (log.push(`edit:${id}:${edit.kind}`), { ok: true, value: { mode: "suggested", suggestion: {} as never, folio: id === SECRET ? ref(SECRET, "Salary bands") : ref(id, "Refunds policy") } }), |
| 334 | share: async (_v, _a, id, users, role) => (log.push(`share:${id}:${users.join(",")}:${role}`), { ok: true, value: null }), |
| 335 | sendLink: async (asker, link) => (log.push(`dm:${asker.username}:${link.path}`), true), |
| 336 | }; |
| 337 | } |
| 338 | |
| 339 | const dmWithCal = () => Audience.build("acme", "cal", audienceWorld({ kind: "dm", member_user_ids: ["cal"], member_count: 1 }, [member("cal", false)], {})); |
| 340 | const annAndBob = () => Audience.build("acme", "ann", audienceWorld({ kind: "private", member_user_ids: ["ann", "bob"], member_count: 2 }, [member("ann"), member("bob")], {})); |
| 341 | const publicChannel = () => Audience.build("acme", "ann", audienceWorld({ kind: "public", member_user_ids: ["ann"], member_count: 30 }, [member("ann")], {})); |
| 342 | |
| 343 | test("someone without Code still gets artifacts; what they can't read is withheld, never named", async () => { |
| 344 | const log: string[] = []; |
| 345 | const box = new ToolBox(await dmWithCal(), { ...readPorts, folios: folioWorld(log) }, ctx, [], actions([])); |
| 346 | const names = box.definitions().map((t) => t.name); |
| 347 | for (const name of ["search_artifacts", "read_artifact", "list_spaces", "stale_artifacts", "create_artifact", "edit_artifact", "share_artifact"]) assert.ok(names.includes(name), name); |
| 348 | assert.ok(!names.includes("read_file"), "still no code"); |
| 349 | assert.ok(!names.includes("query_data"), "not until dashboards"); |
| 350 | assert.ok(box.definitions().filter((t) => t.name.endsWith("_artifact") || t.name.endsWith("_artifacts")).every((t) => /not a workflow run's build artifacts/.test(t.description))); |
| 351 | await box.run("search_artifacts", { query: "refund window", kind: "doc", space: "engineering" }); |
| 352 | assert.deepEqual(log, ['search:refund window:{"kind":"people","user_ids":["cal"]}:spc_eng:doc']); |
| 353 | assert.equal((await box.run("search_artifacts", { query: "refunds", space: "Secret space" })).text, WITHHELD); |
| 354 | assert.equal((await box.run("search_artifacts", { query: "refunds", kind: "spreadsheet" })).outcome, "refused"); |
| 355 | assert.equal((await box.run("read_artifact", { id: "fol_01jabcdefghjkmnpqrstvwxyz9" })).text, WITHHELD); |
| 356 | const read = (await box.run("read_artifact", { id: `https://g1t.sh/acme/-/artifacts/refunds-policy-${FOL}?v=2` })).text; |
| 357 | assert.match(read, /Refunds policy/); |
| 358 | assert.match(read, /Top-level blocks: b1 heading 1/); |
| 359 | assert.match(read, /you can suggest edits/); |
| 360 | assert.match((await box.run("list_spaces", {})).text, /- Engineering \(id spc_eng, team; you can edit; about acme\/web\): How we build/); |
| 361 | assert.equal((await box.run("edit_artifact", { id: FOL, target: "section", markdown: "x" })).outcome, "refused", "a section edit names its heading"); |
| 362 | // The old Docs tools are gone. |
| 363 | assert.equal((await box.run("search_docs", { query: "refunds" })).outcome, "refused"); |
| 364 | // Without an artifacts service, no artifact tools. |
| 365 | assert.ok(!new ToolBox(await dmWithCal(), readPorts, ctx, [], actions([])).definitions().some((t) => t.name === "search_artifacts")); |
| 366 | }); |
| 367 | |
| 368 | test("artifact ids come from ids or any artifact link", () => { |
| 369 | assert.equal(folioRef(FOL), FOL); |
| 370 | assert.equal(folioRef(`/acme/-/artifacts/refunds-policy-${FOL}`), FOL); |
| 371 | assert.equal(folioRef(`https://g1t.sh/acme/-/artifacts/refunds-policy-${FOL}?x=1#h`), FOL); |
| 372 | assert.equal(folioRef(`/acme/-/artifacts/${FOL}/`), FOL); |
| 373 | assert.equal(folioRef("/acme/-/docs/general/refunds-pag_01jabc"), null, "an old Docs page is not an artifact"); |
| 374 | assert.equal(folioRef("fol_short"), null); |
| 375 | assert.equal(folioRef(" "), null); |
| 376 | assert.deepEqual(sourceLink("https://g1t.sh/acme/-/chat/c/general?thread=msg_1"), { title: "A conversation", href: "/acme/-/chat/c/general?thread=msg_1" }); |
| 377 | assert.deepEqual(sourceLink("/acme/-/chat/c/general?thread=msg_1"), { title: "A conversation", href: "/acme/-/chat/c/general?thread=msg_1" }); |
| 378 | assert.equal(sourceLink("//evil.example/x"), null); |
| 379 | assert.equal(sourceLink("javascript:alert(1)"), null); |
| 380 | assert.equal(sourceLink(""), null); |
| 381 | }); |
| 382 | |
| 383 | test("only docs can be made for now: other kinds answer plainly and make nothing", async () => { |
| 384 | const log: string[] = []; |
| 385 | const box = new ToolBox(await dmWithCal(), { ...readPorts, folios: folioWorld(log) }, ctx, [], actions([])); |
| 386 | for (const kind of ["slides", "design", "dashboard"]) { |
| 387 | const made = await box.run("create_artifact", { kind, title: "Q4 roadmap", content: "# Q4" }); |
| 388 | assert.equal(made.outcome, "refused"); |
| 389 | assert.match(made.text, /Slides, designs and dashboards aren't available yet: only docs can be made for now/); |
| 390 | } |
| 391 | assert.equal((await box.run("create_artifact", { kind: "spreadsheet", title: "x", content: "x" })).outcome, "refused"); |
| 392 | assert.deepEqual(log, [], "nothing was made"); |
| 393 | assert.equal((await box.run("create_artifact", { kind: "doc", title: "Q4 roadmap", content: "# Q4" })).outcome, "allowed"); |
| 394 | assert.deepEqual(log, ['create:doc:"private":'], "a DM with one person: their Private"); |
| 395 | }); |
| 396 | |
| 397 | test("where a written-up doc goes: the conversation, a space, Private, or the General space in public", async () => { |
| 398 | const log: string[] = []; |
| 399 | const box = new ToolBox(await annAndBob(), { ...readPorts, folios: folioWorld(log) }, ctx, [], actions([])); |
| 400 | const make = (where: unknown, source?: string) => box.run("create_artifact", { kind: "doc", title: "Decision", content: "We ship Thursday.", where, source }); |
| 401 | assert.match((await make(undefined)).text, new RegExp(`Wrote Decision \\(/acme/-/artifacts/x-${NEW}, id ${NEW}\\)`)); |
| 402 | await make("conversation", "https://g1t.sh/acme/-/chat/c/ops?thread=msg_9"); |
| 403 | await make("private"); |
| 404 | await make({ space: "Engineering" }); |
| 405 | await make("engineering"); |
| 406 | assert.deepEqual(log, [ |
| 407 | 'create:doc:{"conversation":["ann","bob"]}:', |
| 408 | 'create:doc:{"conversation":["ann","bob"]}:/acme/-/chat/c/ops?thread=msg_9', |
| 409 | 'create:doc:"private":', |
| 410 | 'create:doc:{"space_id":"spc_eng"}:', |
| 411 | 'create:doc:{"space_id":"spc_eng"}:', |
| 412 | ]); |
| 413 | assert.equal((await make({ space: "Nowhere" })).outcome, "refused"); |
| 414 | |
| 415 | // In a public channel there is no list of people: the General space, else Private. |
| 416 | const pub: string[] = []; |
| 417 | const open = new ToolBox(await publicChannel(), { ...readPorts, folios: folioWorld(pub) }, ctx, [], actions([])); |
| 418 | await open.run("create_artifact", { kind: "doc", title: "Decision", content: "x" }); |
| 419 | await open.run("create_artifact", { kind: "doc", title: "Decision", content: "x", where: "conversation" }); |
| 420 | assert.deepEqual(pub, ['create:doc:{"space_id":"spc_general"}:', 'create:doc:{"space_id":"spc_general"}:']); |
| 421 | const barred: string[] = []; |
| 422 | const noGeneral = new ToolBox(await publicChannel(), { ...readPorts, folios: folioWorld(barred, { forbidden: "spc_general", hidden: new Set(["new"]) }) }, ctx, [], actions([])); |
| 423 | const made = await noGeneral.run("create_artifact", { kind: "doc", title: "Decision", content: "x" }); |
| 424 | assert.deepEqual(barred, ['create:doc:{"space_id":"spc_general"}:', 'create:doc:"private":', `dm:ann:/acme/-/artifacts/x-${NEW}`]); |
| 425 | assert.ok(!made.text.includes("Decision"), "a private doc made in public isn't named there"); |
| 426 | assert.match(made.text, /sent the link to @ann directly/); |
| 427 | }); |
| 428 | |
| 429 | test("an artifact someone here can't read is never quoted: the link goes to the asker, and what is remembered stays theirs", async () => { |
| 430 | const log: string[] = []; |
| 431 | const remembered: boolean[] = []; |
| 432 | const acts: ActionPorts = { ...actions([]), remember: async (_body, _scope, onlyForAsker) => (remembered.push(!!onlyForAsker), { ok: true, message: "ok" }) }; |
| 433 | const box = new ToolBox(await annAndBob(), { ...readPorts, folios: folioWorld(log) }, ctx, [], acts); |
| 434 | await box.run("remember", { fact: "Before reading anything" }); |
| 435 | const read = await box.run("read_artifact", { id: `/acme/-/artifacts/salary-bands-${SECRET}` }); |
| 436 | assert.equal(read.outcome, "withheld"); |
| 437 | assert.ok(!read.text.includes("Bands") && !read.text.includes("Salary"), "neither its content nor its title"); |
| 438 | assert.match(read.text, /say you found it and that you've sent the link to @ann directly/); |
| 439 | assert.deepEqual(log, [`dm:ann:/acme/-/artifacts/x-${SECRET}`]); |
| 440 | await box.run("remember", { fact: "Bands are reviewed in March" }); |
| 441 | assert.deepEqual(remembered, [false, true]); |
| 442 | const edited = await box.run("edit_artifact", { id: SECRET, target: "append", markdown: "x" }); |
| 443 | assert.equal(edited.text, "Suggested: people accept or reject it there.", "an edit there isn't named here either"); |
| 444 | |
| 445 | // In a public channel, even a doc everyone can read counts as the workspace's. |
| 446 | const pub: boolean[] = []; |
| 447 | const open = new ToolBox(await publicChannel(), { ...readPorts, folios: folioWorld([]) }, ctx, [], { ...actions([]), remember: async (_b, _s, only) => (pub.push(!!only), { ok: true, message: "ok" }) }); |
| 448 | await open.run("read_artifact", { id: FOL }); |
| 449 | await open.run("remember", { fact: "Refunds are 30 days" }); |
| 450 | assert.deepEqual(pub, [false]); |
| 451 | }); |
| 452 | |
| 453 | test("an agent shares only in a private conversation, only with people in it, to view or comment", async () => { |
| 454 | const log: string[] = []; |
| 455 | const box = new ToolBox(await annAndBob(), { ...readPorts, folios: folioWorld(log) }, ctx, [], actions([])); |
| 456 | assert.equal((await box.run("share_artifact", { id: FOL, people: ["@bob"], role: "comment" })).outcome, "allowed"); |
| 457 | assert.match((await box.run("share_artifact", { id: FOL, people: ["carol"], role: "view" })).text, /@carol isn't in this conversation/); |
| 458 | assert.equal((await box.run("share_artifact", { id: FOL, people: ["bob"], role: "edit" })).outcome, "refused"); |
| 459 | assert.deepEqual(log, [`share:${FOL}:bob:comment`]); |
| 460 | const open = new ToolBox(await publicChannel(), { ...readPorts, folios: folioWorld(log) }, ctx, [], actions([])); |
| 461 | assert.match((await open.run("share_artifact", { id: FOL, people: ["bob"], role: "view" })).text, /only in a direct message or a private channel/); |
| 462 | }); |
| 463 | |
| 464 | test("a doc reads as Markdown with where it is, what the agent may do, and its block ids", () => { |
| 465 | const text = folioReadText(agentRead(FOL, "Refunds policy", "# Refunds policy\n\n30 days.", true)); |
| 466 | assert.equal( |
| 467 | text, |
| 468 | `# Refunds policy (/acme/-/artifacts/x-${FOL}, id ${FOL})\nA doc, in the General space; you can suggest edits. Edited 2026-10-01T10:00.\nTop-level blocks: b1 heading 1\n\n# Refunds policy\n\n30 days.`, |
| 469 | ); |
| 470 | }); |
| 471 | |
| 472 | test("a technical writer's duties suggest keeping the docs current when a pull request merges", () => { |
| 473 | const inky = ["Update the docs after every change that makes them wrong", "Turn decisions made in chat into pages", "Write release notes and the weekly summary"]; |
| 474 | const names = suggestRoutines(inky, []).map((s) => [s.routine.name, s.routine.events]); |
| 475 | assert.deepEqual(names[0], ["Keep the docs current", ["pull_merged"]]); |
| 476 | assert.ok(names.some(([name]) => name === "Weekly summary")); |
| 477 | }); |