Skip to content
Open
12 changes: 7 additions & 5 deletions src-tauri/src/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,12 +532,14 @@ fn validate_sidebar_payload(coordinate: &str, value: &serde_json::Value) -> Resu

"main-agent" => {
let agent = data.get("agent").ok_or_else(invalid)?;
// A null agent saves none: the owner removed their Bestie.
if data.len() != 2
|| !agent.as_str().is_some_and(|v| {
v.len() == 64
&& v.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
|| !(agent.is_null()
|| agent.as_str().is_some_and(|v| {
v.len() == 64
&& v.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}))
{
return Err(invalid());
}
Expand Down
13 changes: 11 additions & 2 deletions src-tauri/src/identity/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ pub(crate) fn write_denied_fixture() -> (IdentityHost, impl Fn()) {
}

#[tokio::test]
async fn main_agent_signer_admits_only_one_agent() {
async fn main_agent_signer_admits_one_agent_or_none() {
use serde_json::json;
let host = super::IdentityHost::fixture();
let payload = json!({"version": 1, "agent": "ab".repeat(32)});
Expand All @@ -746,9 +746,18 @@ async fn main_agent_signer_admits_only_one_agent() {
host.decode_sidebar(vec![event]).await.unwrap()["main-agent"],
payload
);
let none = json!({"version": 1, "agent": null});
let event = host
.sign_sidebar("main-agent".into(), none.clone(), 101)
.await
.unwrap();
host.admit_sidebar(event.clone()).await.unwrap();
assert_eq!(
host.decode_sidebar(vec![event]).await.unwrap()["main-agent"],
none
);
for invalid in [
json!({"version": 1}),
json!({"version": 1, "agent": null}),
json!({"version": 2, "agent": "ab".repeat(32)}),
json!({"version": 1, "agent": "AB".repeat(32)}),
json!({"version": 1, "agent": "ab".repeat(31)}),
Expand Down
46 changes: 46 additions & 0 deletions src/bundled/agents/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3657,6 +3657,52 @@ it("offers Make my bestie from the card's actions button", async () => {
expect(screen.getByRole("gridcell", { name: "Bea" })).toBeVisible();
});

it("offers Make my bestie only for an agent on this device, and says why", async () => {
const user = userEvent.setup();
const ada = agent("Ada", 1);
const sky: Agent = { ...agent("Sky", 2), place: "hosted" };
const view = setup([ada, sky]);
const rows = [ada, sky].map((each) => ({
pubkey: each.pubkey,
name: each.name,
managed: true,
}));
const choices = {
status: "ready",
complete: true,
pending: false,
identities: rows,
selectable: rows,
archives: { status: "unavailable", archived: [] },
};
const connection = {
status: "ready",
scope: "local-only-bestie",
session: {
agentChoices: {
snapshot: () => choices,
subscribe: () => () => {},
retain: () => () => {},
},
},
};
const relay = {
snapshot: () => connection,
subscribe: () => () => {},
} as unknown as RelayData;
view.rerender(<AgentsPage agents={view.agents} relay={relay} />);
const card = await screen.findByRole("gridcell", { name: /^Sky/ });
await user.click(
within(card).getByRole("button", { name: "Actions for Sky" }),
);
expect(
await screen.findByRole("menuitem", { name: "Make my bestie" }),
).toHaveAttribute("aria-disabled", "true");
expect(
screen.getByText("Only agents on this device can be your Bestie for now."),
).toBeVisible();
});

it("keeps a failed Bestie replacement visible and retries the account save", async () => {
const user = userEvent.setup();
const ada = agent("Ada", 1);
Expand Down
10 changes: 8 additions & 2 deletions src/bundled/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import {
type SharedDefinition,
} from "../../features/agents/agent-share";
import { useDefaultAgent } from "../../features/solo/default-agent";
import { useBestieChoice } from "./BestieChoice";
import { BESTIE_LOCAL_ONLY, useBestieChoice } from "./BestieChoice";
import type { RelayData } from "../../features/relay/service";
import type { Navigation } from "../../features/navigation/controller";
import { targetKey } from "../../features/navigation/targets";
Expand Down Expand Up @@ -91,6 +91,7 @@ import { Input } from "../../shared/design-system/ui/Input";
import {
ContextMenuRoot,
ContextMenuTrigger,
MenuNote,
MenuSeparator,
MenuPopup,
MenuItem,
Expand Down Expand Up @@ -993,9 +994,14 @@ function InventoryCard({
<>
{bestie && !bestie.selected && (
<>
<MenuItem disabled={!bestie.available} onClick={bestie.onMake}>
{/* A remote agent isn't set up to be Bestie yet. */}
<MenuItem
disabled={!bestie.available || agent.place !== "here"}
onClick={bestie.onMake}
>
Make my bestie
</MenuItem>
{agent.place !== "here" && <MenuNote>{BESTIE_LOCAL_ONLY}</MenuNote>}
<MenuSeparator />
</>
)}
Expand Down
5 changes: 5 additions & 0 deletions src/bundled/agents/BestieChoice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export function onBestieMade(listener: (pubkey: string) => void) {
return () => void made.delete(listener);
}

/** Why only some agents can be made Bestie: a remote one isn't set up for
* it yet. */
export const BESTIE_LOCAL_ONLY =
"Only agents on this device can be your Bestie for now.";

/** Making an agent Bestie, wherever it is offered: `make` asks before
* replacing an explicitly saved Bestie, in the dialog `view` renders. */
export function useBestieChoice(session: RelaySession, scope: string) {
Expand Down
135 changes: 130 additions & 5 deletions src/bundled/bestie/BestieTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, expect, it, vi } from "vitest";
import type { Agent, AgentChange, Agents } from "../../features/agents/service";
import type { RelayData } from "../../features/relay/service";
import { writeView } from "../../shared/view-state";
import { setDefaultAgent } from "../../features/solo/default-agent";
import { onBestieMade } from "../agents/BestieChoice";
import { bestieTab, formerBestieTab } from "./BestieTab";
import { validateObject } from "../../features/agents/attention";
Expand Down Expand Up @@ -34,13 +35,17 @@ const member = (name: string, digit: string): Agent =>
const ada = member("Ada", "a");
const bea = member("Bea", "b");

/** Agents that save as a synced editor would: the agent's next config. */
function host(types = ["test/echo"]) {
/** Agents that save as a synced editor would: the agent's next config. The
* agents on this device are Ada and Bea. */
function host(types = ["test/echo"], here: Agent[] = [ada, bea]) {
const save = vi.fn<Agents["save"]>(async () => {});
const snapshot = { agents: here, running: here };
const agents = {
save,
types: () => types.map((key) => ({ key, adopt: () => ({}) })),
} as unknown as Pick<Agents, "save" | "types">;
snapshot: () => snapshot,
subscribe: () => () => {},
} as unknown as Pick<Agents, "save" | "types" | "snapshot" | "subscribe">;
return { agents, save };
}

Expand All @@ -61,8 +66,9 @@ function community(saved: string | undefined, agents = [ada, bea]) {
};
const mainAgent = {
read: vi.fn(async () => record),
write: vi.fn(async (agent: string) => {
record = agent;
// None reads back as no record, as the account's reader does.
write: vi.fn(async (agent: string | null) => {
record = agent ?? undefined;
}),
};
const connection = {
Expand Down Expand Up @@ -143,13 +149,54 @@ it("shows on the saved Bestie only, and moves when Change picks another", async
stop?.();
});

it("offers only agents on this device in Change, and says why", async () => {
const user = userEvent.setup();
const cara = { ...member("Cara", "c"), place: "hosted" } as Agent;
const { relay } = community(ada.pubkey, [ada, bea, cara]);
const Tab = bestieTab(relay, host([], [ada, bea, cara]).agents).component;
await act(async () => {
render(<Tab agent={ada} />);
});
await user.click(screen.getByRole("button", { name: "Change" }));
expect(await screen.findByRole("menuitem", { name: "Bea" })).toBeVisible();
expect(screen.queryByRole("menuitem", { name: "Cara" })).toBeNull();
expect(
screen.getByText("Only agents on this device can be your Bestie for now."),
).toBeVisible();
});

it("keeps Change unavailable while there is no other agent to choose", async () => {
const { relay } = community(ada.pubkey, [ada]);
const Tab = bestieTab(relay, host().agents).component;
await act(async () => {
render(<Tab agent={ada} />);
});
expect(screen.getByRole("button", { name: "Change" })).toBeDisabled();
// The menu's note can't be opened, so the row says why instead.
expect(
screen.getByText(
"It's pinned at the top of Buzz, with your DM as its home. Only agents on this device can be your Bestie for now.",
),
).toBeVisible();
});

it("offers no way back to a former Bestie that isn't on this device, and says why", async () => {
const { relay } = community(bea.pubkey);
const remote = {
...ada,
place: "hosted",
config: { instructions: withFeature("Be brief.", memory, true) },
} as Agent;
const Tab = formerBestieTab(relay, host([], [bea, remote]).agents).component;
await act(async () => {
render(<Tab agent={remote} />);
});
expect(
screen.getByRole("button", { name: "Make my Bestie again" }),
).toBeDisabled();
expect(
screen.getByText("Only agents on this device can be your Bestie for now."),
).toBeVisible();
});

it("tells the host only when the Bestie or its community changes", async () => {
Expand Down Expand Up @@ -448,6 +495,84 @@ it("shows a Former Bestie tab while a former Bestie has a setting on, with a way
stop?.();
});

it("removes the Bestie after asking, and shows the agent as a Former Bestie", async () => {
const user = userEvent.setup();
const { relay, mainAgent } = community(ada.pubkey);
const agents = host().agents;
const tab = bestieTab(relay, agents);
const former = formerBestieTab(relay, agents);
const remembering = {
...ada,
config: { instructions: withFeature("Be brief.", memory, true) },
} as Agent;
const stop = tab.subscribe?.(() => {});
await vi.waitFor(() => expect(tab.when?.(ada)).toBe(true));
expect(former.when?.(remembering)).toBe(false);
const Tab = tab.component;
render(<Tab agent={remembering} />);
// A failed save says so and keeps the Bestie.
mainAgent.write.mockRejectedValueOnce(new Error("offline"));
await user.click(screen.getByRole("button", { name: "Remove" }));
let ask = await screen.findByRole("alertdialog", {
name: "Remove Ada as your Bestie?",
});
expect(ask).toHaveTextContent("Ada stays in Agents");
expect(ask).toHaveTextContent("Its check-ins stop.");
expect(ask).toHaveTextContent("memory rules and private channels stay");
await user.click(
within(ask).getByRole("button", { name: "Remove as Bestie" }),
);
expect(await within(ask).findByRole("alert")).toHaveTextContent(
"Removing your bestie couldn't be saved to your account. Try again.",
);
expect(tab.when?.(ada)).toBe(true);
await user.click(within(ask).getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("alertdialog")).toBeNull();

await user.click(screen.getByRole("button", { name: "Remove" }));
ask = await screen.findByRole("alertdialog", {
name: "Remove Ada as your Bestie?",
});
await user.click(
within(ask).getByRole("button", { name: "Remove as Bestie" }),
);
expect(mainAgent.write).toHaveBeenLastCalledWith(null);
await vi.waitFor(() => expect(tab.when?.(ada)).toBe(false));
expect(screen.queryByRole("alertdialog")).toBeNull();
// With no Bestie known, the agent with memory on is a Former Bestie.
expect(former.when?.(remembering)).toBe(true);
expect(former.when?.(bea)).toBe(false);
stop?.();
});

it("shows no Former Bestie tab while the account's choice is unread or unreadable", async () => {
const { relay, mainAgent } = community(undefined);
mainAgent.read.mockRejectedValueOnce(new Error("offline"));
const former = formerBestieTab(relay, host().agents);
const remembering = {
...ada,
config: { instructions: withFeature("Be brief.", memory, true) },
} as Agent;
const stop = former.subscribe?.(() => {});
await act(async () => {});
expect(former.when?.(remembering)).toBe(false);
stop?.();
});

it("offers no Remove without an account to save none to", async () => {
const { relay } = community(undefined);
// A device-only session would pick another agent at once.
delete (relay.snapshot() as { session: { mainAgent?: unknown } }).session
.mainAgent;
setDefaultAgent(SCOPE, ada.pubkey);
const Tab = bestieTab(relay, host().agents).component;
await act(async () => {
render(<Tab agent={ada} />);
});
expect(screen.getByRole("button", { name: "Change" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
});

it("lets a former Bestie's leftover check-in be turned off", async () => {
const user = userEvent.setup();
const { relay } = community(bea.pubkey);
Expand Down
Loading
Loading