Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/features/agents/agents-runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, it, vi } from "vitest";
import { AgentsRunner, type RunnerManager } from "./agents-runner";
import { createLocalAgentActivity } from "./local-activity";
import type { Member } from "./records";
import { memoryStorage } from "./test-fakes";

const owner = "b".repeat(64);
const ada: Member = {
key: "a".repeat(64),
pubkey: "a".repeat(64),
relay: "wss://relay.example",
owner,
type: "test/agent",
name: "Ada",
deleted: false,
};
const gone = "c".repeat(64);

function saved() {
const storage = memoryStorage();
for (const pubkey of [ada.pubkey, gone])
storage.setItem(
`buzz.agent.v1:${pubkey}`,
JSON.stringify({ pubkey, attention: {}, config: {} }),
);
return storage;
}

function runner(storage: ReturnType<typeof memoryStorage>) {
const manager = {
ownershipChanged: () => {},
changed: () => {},
open: () => undefined,
types: () => [],
defaults: () => {
throw new Error("No types here");
},
} as unknown as RunnerManager;
return new AgentsRunner(
storage,
undefined,
createLocalAgentActivity(),
undefined,
manager,
);
}

it("forgets records no membership keeps only once the whole list is read", async () => {
const storage = saved();
const agents = runner(storage);
expect(agents.records.keys().sort()).toEqual([ada.pubkey, gone]);
// Before native's list is read, an empty list names nobody.
agents.members([], owner, false);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(agents.records.keys().sort()).toEqual([ada.pubkey, gone]);
agents.members([ada], owner, true);
await vi.waitFor(() => expect(agents.records.keys()).toEqual([ada.pubkey]));
expect(storage.getItem(`buzz.agent.v1:${gone}`)).toBeNull();
agents.clear();
});
50 changes: 46 additions & 4 deletions src/features/agents/agents-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ export class AgentsRunner {
/** The memberships it last decided from, and their viewer. */
private identities: readonly Member[] = [];
private viewer: string | undefined;
/** The last full list it forgot records by. */
private list: string | undefined;
private readonly listeners = new Set<() => void>();
private readonly recordListeners = new Set<() => void>();

constructor(
storage: AgentStore,
Expand All @@ -111,7 +114,10 @@ export class AgentsRunner {
console.warn(`Agent type ${type} has invalid defaults`, error);
}
},
changed: () => this.changed(),
changed: () => {
for (const listener of [...this.recordListeners]) listener();
this.changed();
},
// Its timers waited for this change, saved or failed.
settled: (key) => this.runners.wake(key),
});
Expand Down Expand Up @@ -276,16 +282,51 @@ export class AgentsRunner {
this.listeners.delete(listener);
};
}
/** Hears each change to a record. */
subscribeRecords(listener: () => void) {
this.recordListeners.add(listener);
return () => {
this.recordListeners.delete(listener);
};
}
private notify() {
for (const listener of [...this.listeners]) listener();
}
/** The viewer's memberships, as the Manager last sent them: connects each
* community where the viewer has an agent, and decides again what runs. */
members(identities: readonly Member[], viewer: string | undefined) {
/** The memberships on this device, as the Manager last sent them, and
* whether they are all of them (`loaded`): connects each community where
* the viewer has an agent, decides again what runs, and forgets records
* no membership keeps. */
members(
identities: readonly Member[],
viewer: string | undefined,
loaded: boolean,
) {
// Only a new list can name a record no membership keeps.
const list = JSON.stringify(identities);
const listed = loaded && list !== this.list;
if (loaded) this.list = list;
this.identities = identities;
this.viewer = viewer;
this.connections.watch(identities, viewer);
this.update();
if (listed) this.forget();
}
/** Removes the settings whose agent is gone, or that a community it left
* kept: they do nothing. Its home record keeps its config while it is
* anywhere. */
private forget() {
const live = new Set(
this.identities
.filter((saved) => !saved.deleted)
.flatMap((saved) => [saved.key, saved.pubkey]),
);
for (const key of this.records.keys())
if (!live.has(key))
void this.records
.edit(key, { kind: "remove" })
.catch((error) =>
console.warn(`Agent ${key} settings were not removed`, error),
);
}
/** Decides again what runs once a record, a turn in progress or one of its
* sessions changed, and lets the Manager show it. */
Expand Down Expand Up @@ -445,6 +486,7 @@ export class AgentsRunner {
this.runners.clear();
this.identities = [];
this.viewer = undefined;
this.list = undefined;
this.state = { running: [], interrupted: [] };
}

Expand Down
20 changes: 20 additions & 0 deletions src/features/agents/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,23 @@ it("does not answer a call for a page that has since reloaded", async () => {
release("slow");
await expect(answer).resolves.toBe("fast");
});

it("sends what a command changed before its result", async () => {
const count = store<number>(1);
const { first, second } = pages();
first({
topics: { count: () => count },
commands: {
bump: async () => {
await Promise.resolve();
count.set(count.get() + 1);
return "done";
},
},
});
const manager = second();
manager.watch("count", null, () => {});
await vi.waitFor(() => expect(manager.read("count")).toBe(1));
await expect(manager.call("bump")).resolves.toBe("done");
expect(manager.read("count")).toBe(2);
});
146 changes: 119 additions & 27 deletions src/features/agents/runner-link.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { afterEach, expect, it, vi } from "vitest";
import { link, type Link, type LinkPort } from "./link";
import { createLocalAgentActivity } from "./local-activity";
import type { Member } from "./records";
import { agentKey } from "./membership";
import type { Member, RecordEdit } from "./records";
import {
linkedActivity,
managerSide,
runnerSide,
serveActivity,
} from "./runner-link";
import type { Agent } from "./service";
import type { AgentRecord } from "./store";

const open: Link[] = [];
afterEach(() => {
Expand Down Expand Up @@ -111,21 +113,25 @@ function fakeRunner() {
const listeners = new Set<() => void>();
let running: readonly Agent[] = [];
const left = new Set<string>();
const recordListeners = new Set<() => void>();
let saved: Record<string, AgentRecord> = {};
const runner = {
members: vi.fn((identities: readonly Member[], _viewer?: string) => {
const next = identities.map(
(identity) =>
running.find(
(agent) =>
agent.key === identity.key && agent.name === identity.name,
) ?? agentOf(identity),
);
const changed =
next.length !== running.length ||
next.some((agent, index) => agent !== running[index]);
running = next;
if (changed) for (const listener of [...listeners]) listener();
}),
members: vi.fn(
(identities: readonly Member[], _viewer?: string, _loaded?: boolean) => {
const next = identities.map(
(identity) =>
running.find(
(agent) =>
agent.key === identity.key && agent.name === identity.name,
) ?? agentOf(identity),
);
const changed =
next.length !== running.length ||
next.some((agent, index) => agent !== running[index]);
running = next;
if (changed) for (const listener of [...listeners]) listener();
},
),
running: () => running,
interrupted: () => running.filter((agent) => left.has(agent.key)),
subscribe(listener: () => void) {
Expand All @@ -134,21 +140,48 @@ function fakeRunner() {
listeners.delete(listener);
};
},
records: {
all: () => Object.values(saved),
edit: vi.fn(async (key: string, edit: RecordEdit) => {
await Promise.resolve();
if (edit.kind === "remove") {
const { [key]: _, ...rest } = saved;
saved = rest;
} else if (edit.kind === "create")
saved = { ...saved, [key]: edit.record };
for (const listener of [...recordListeners]) listener();
}),
},
subscribeRecords(listener: () => void) {
recordListeners.add(listener);
return () => {
recordListeners.delete(listener);
};
},
};
return { runner, left };
}

/** A new agent's settings, as its type gives them. */
const defaults = vi.fn(
({ pubkey }: Pick<Member, "pubkey">): AgentRecord => ({
pubkey,
attention: {},
config: { reply: "default" },
}),
);

function split(identities: Member[] = []) {
const { runner, left } = fakeRunner();
const channel = new MessageChannel();
let shown: Member[] = identities;
const changed = vi.fn();
const runnerLink = runnerSide(channel.port1, runner);
const manager = managerSide(
channel.port2,
() => ({ identities: shown, viewer: owner }),
const manager = managerSide(channel.port2, {
members: () => ({ loaded: true, identities: shown, viewer: owner }),
defaults,
changed,
);
});
open.push(runnerLink);
closing.push(manager);
return {
Expand All @@ -172,7 +205,7 @@ it("lets the Runner decide from the Manager's memberships, and waits for it", as
const { runner, manager, show } = split();
show([ada]);
await manager.decided();
expect(runner.members).toHaveBeenLastCalledWith([ada], owner);
expect(runner.members).toHaveBeenLastCalledWith([ada], owner, true);
expect(manager.running()).toEqual([agentOf(ada)]);
expect(manager.agent(ada.key)?.name).toBe("Ada");
expect(Object.isFrozen(manager.running()[0])).toBe(true);
Expand All @@ -196,6 +229,7 @@ it("settles a wait even when nothing that runs changed", async () => {
expect(runner.members).toHaveBeenLastCalledWith(
[{ ...ada, deleting: true }],
owner,
true,
);
expect(manager.running()).toBe(shown);
});
Expand Down Expand Up @@ -257,11 +291,15 @@ it("does not take what the Runner decided for a page that reloaded as an answer"
const channel = new MessageChannel();
to = channel.port1;
channel.port1.onmessage = (event) => port.onmessage?.(event);
const page = managerSide(
channel.port2,
() => ({ identities: identities(), viewer: owner }),
() => {},
);
const page = managerSide(channel.port2, {
members: () => ({
loaded: true,
identities: identities(),
viewer: owner,
}),
defaults: () => undefined,
changed: () => {},
});
closing.push(page);
return page;
};
Expand All @@ -275,11 +313,65 @@ it("does not take what the Runner decided for a page that reloaded as an answer"
shown = [ada, bo];
first.changed();
await first.decided();
expect(runner.members).toHaveBeenLastCalledWith([ada, bo], owner);
expect(runner.members).toHaveBeenLastCalledWith([ada, bo], owner, true);
// The reloaded page's first revision is not the Runner's last one.
const second = load(() => [bo]);
second.changed();
await second.decided();
expect(runner.members).toHaveBeenLastCalledWith([bo], owner);
expect(runner.members).toHaveBeenLastCalledWith([bo], owner, true);
expect(second.running().map((agent) => agent.name)).toEqual(["Bo"]);
});

it("shows the Runner's records, and settles an edit once it is shown", async () => {
const ada = member("a".repeat(64), "Ada");
const { runner, manager, show, changed } = split();
show([ada]);
await manager.decided();
// Until the Runner keeps one, its type's defaults, the same each time.
const first = manager.records.settingsOf(ada);
expect(first.config).toEqual({ reply: "default" });
expect(manager.records.settingsOf(ada)).toBe(first);
expect(manager.records.keys()).toEqual([]);
const record = { pubkey: ada.key, attention: {}, config: { reply: "mine" } };
changed.mockClear();
await manager.records.edit(ada.key, { kind: "create", record });
expect(runner.records.edit).toHaveBeenCalledWith(ada.key, {
kind: "create",
record,
});
expect(changed).toHaveBeenCalled();
expect(manager.records.keys()).toEqual([ada.key]);
const kept = manager.records.recordOf(ada);
expect(kept).toEqual(record);
// An unchanged record keeps its identity when another changes.
const bo = member("c".repeat(64), "Bo");
await manager.records.edit(bo.key, {
kind: "create",
record: { ...record, pubkey: bo.key },
});
expect(manager.records.recordOf(ada)).toBe(kept);
await manager.records.edit(ada.key, { kind: "remove" });
expect(manager.records.keys()).toEqual([bo.key]);
});

it("gives a membership outside its home its own attention and the agent's config", async () => {
const ada = member("a".repeat(64), "Ada");
const away: Member = {
...ada,
key: agentKey(ada.pubkey, "https://other.example", "https://relay.example"),
relay: "wss://other.example",
home: "wss://relay.example",
};
const { manager, show } = split();
show([ada, away]);
await manager.decided();
await manager.records.edit(ada.key, {
kind: "create",
record: { pubkey: ada.key, attention: {}, config: { reply: "home" } },
});
expect(manager.records.recordOf(away)).toEqual({
pubkey: away.key,
attention: {},
config: { reply: "home" },
});
});
Loading
Loading