Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default defineConfig({
"**/search-scope-screenshots.spec.ts",
"**/onboarding-docked-cta-screenshots.spec.ts",
"**/identity-key-help.spec.ts",
"**/exact-key-profile.spec.ts",
"**/key-import-reveal.spec.ts",
"**/navigation.spec.ts",
"**/channels.spec.ts",
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/features/agents/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,11 @@ with a TypeScript lookup table or an id comparison in a component.
agent from Agents, a DM, or a channel must expose the same actions, tabs,
fields, and profile-wide activity selection. Caller context may control the
panel shell or return navigation, but must not filter or replace profile
content.
content. Explicit public-key targets are always exact, including stopped,
archived, and relay-only identities. Only explicit persona navigation may
select a representative or offer persona Start; a relay persona link cannot
borrow a local sibling's management controls. See
[the identity contract](../../../../docs/agent-profile-identity.md).
14. **Thinking effort has two surfaces: a local-only WRITE control and a
read-only two-facts DISPLAY.** The write control is `EffortPickerField`
(`ui/EffortPickerField.tsx`), a self-contained section component mounted in
Expand Down
62 changes: 1 addition & 61 deletions desktop/src/features/agents/lib/pickProfileAgent.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
pickDirectProfileAgent,
pickProfileAgent,
} from "./pickProfileAgent.ts";
import { pickProfileAgent } from "./pickProfileAgent.ts";

const NONE_ARCHIVED = () => false;

Expand Down Expand Up @@ -68,60 +65,3 @@ test("a fail-open predicate keeps every instance eligible while loading", () =>
// Fail-open (all false) during the archive-snapshot window: normal ranking.
assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running);
});

test("a direct-opened active instance is never redirected to a sibling", () => {
// "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an
// access edit on Tyler would target the sibling.
const sibling = {
name: "Alpha Sibling",
pubkey: "a".repeat(64),
status: "running",
};
const clicked = {
name: "Tyler Agent",
pubkey: "b".repeat(64),
status: "running",
};

assert.equal(
pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED),
clicked,
);
});

test("a direct-opened inactive instance redirects to the active sibling", () => {
const historical = {
name: "Earlier Parity Agent",
pubkey: "a".repeat(64),
status: "stopped",
};
const current = {
name: "Current Parity Agent",
pubkey: "b".repeat(64),
status: "running",
};

assert.equal(
pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED),
current,
);
});

test("a direct-opened inactive instance with no active sibling stays put", () => {
const clicked = {
name: "Only Instance",
pubkey: "a".repeat(64),
status: "stopped",
};
const otherStopped = {
name: "Another Stopped",
pubkey: "b".repeat(64),
status: "stopped",
};

assert.equal(
pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED),
clicked,
);
assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked);
});
26 changes: 2 additions & 24 deletions desktop/src/features/agents/lib/pickProfileAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { ManagedAgent } from "@/shared/api/types";
* Pick the instance that represents a persona throughout the UI.
*
* A persona can have several historical agent instances. Keeping this rule in
* one place prevents an avatar click on an older message from opening a
* different detail surface than the card in the Agents library.
* one place keeps persona navigation consistent. Explicit pubkey navigation
* never uses this selector: older messages still name their exact author.
*
* Relay-archived instances are never eligible, so an archived record early in
* file order can't hijack the persona target. Returns `undefined` when every
Expand All @@ -28,25 +28,3 @@ export function pickProfileAgent(
return left.name.localeCompare(right.name);
})[0];
}

/**
* Resolve which instance a profile panel opened for `directAgent` should
* show, given every instance of the same persona.
*
* Access edits must target the exact instance the user clicked — resolving a
* running sidebar member to an alphabetically-earlier sibling would let a
* "tighten access" save widen the wrong agent. But when the clicked instance
* is inactive and the persona has an active instance elsewhere (an avatar on
* an old message from a retired instance), redirect to the active one so the
* panel matches the Agents library. The `isArchived` predicate keeps that
* redirect from ever landing on an archived sibling.
*/
export function pickDirectProfileAgent(
directAgent: ManagedAgent,
personaInstances: readonly ManagedAgent[],
isArchived: (pubkey: string) => boolean,
) {
if (isManagedAgentActive(directAgent)) return directAgent;
const canonical = pickProfileAgent(personaInstances, isArchived);
return canonical && isManagedAgentActive(canonical) ? canonical : directAgent;
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ test("a persona target with a live sibling resolves to the live instance", () =>
directManagedAgent: undefined,
isArchived: (pubkey) => pubkey === ARCHIVED_PK,
personaInstances: [archived, live],
preferDirectManagedAgent: false,
preserveRequestedInstance: false,
pubkey: undefined,
});

Expand All @@ -42,8 +40,6 @@ test("a persona target with all instances archived resolves to undefined", () =>
directManagedAgent: undefined,
isArchived: () => true,
personaInstances: [first, second],
preferDirectManagedAgent: false,
preserveRequestedInstance: false,
pubkey: undefined,
});

Expand All @@ -58,8 +54,6 @@ test("an explicit archived pubkey stays exact even when a live sibling exists",
directManagedAgent: archivedDirect,
isArchived: (pubkey) => pubkey === ARCHIVED_PK,
personaInstances: [archivedDirect, live],
preferDirectManagedAgent: false,
preserveRequestedInstance: false,
pubkey: ARCHIVED_PK,
});

Expand All @@ -75,48 +69,42 @@ test("an explicit archived pubkey with no managed record resolves to undefined s
directManagedAgent: undefined,
isArchived: (pubkey) => pubkey === HISTORICAL_PK,
personaInstances: [],
preferDirectManagedAgent: false,
preserveRequestedInstance: false,
pubkey: HISTORICAL_PK,
});

assert.equal(resolved, undefined);
});

test("a preserved requested instance pins the exact record over canonicalization", () => {
test("an explicit stopped instance pins the exact record over canonicalization", () => {
const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" });
const live = agent({ pubkey: LIVE_PK, status: "running" });

const resolved = resolveCanonicalManagedAgent({
directManagedAgent: requested,
isArchived: NONE_ARCHIVED,
personaInstances: [requested, live],
preferDirectManagedAgent: false,
preserveRequestedInstance: true,
pubkey: HISTORICAL_PK,
});

assert.equal(resolved, requested);
});

test("a non-archived historical pubkey canonicalizes to the live persona instance", () => {
// Rule 5: #5788 canonicalization is retained for non-archived navigation.
test("a non-archived historical pubkey stays exact, like archived and active keys", () => {
// History is authored by a key, not its current persona representative.
const requested = agent({ pubkey: HISTORICAL_PK, status: "stopped" });
const live = agent({ pubkey: LIVE_PK, status: "running" });

const resolved = resolveCanonicalManagedAgent({
directManagedAgent: requested,
isArchived: NONE_ARCHIVED,
personaInstances: [requested, live],
preferDirectManagedAgent: false,
preserveRequestedInstance: false,
pubkey: HISTORICAL_PK,
});

assert.equal(resolved, live);
assert.equal(resolved, requested);
});

test("preferDirectManagedAgent keeps a directly opened active instance exact", () => {
test("explicit navigation keeps a directly opened active instance exact", () => {
// The panel's own default: an access edit must target the clicked instance,
// not an alphabetically-earlier active sibling.
const sibling = agent({ name: "Alpha", pubkey: LIVE_PK, status: "running" });
Expand All @@ -130,28 +118,37 @@ test("preferDirectManagedAgent keeps a directly opened active instance exact", (
directManagedAgent: clicked,
isArchived: NONE_ARCHIVED,
personaInstances: [sibling, clicked],
preferDirectManagedAgent: true,
preserveRequestedInstance: false,
pubkey: HISTORICAL_PK,
});

assert.equal(resolved, clicked);
});

test("an explicit archived pubkey stays exact even with preferDirectManagedAgent", () => {
// Rule 2 wins over the direct-preference redirect: a deliberately opened
// archived instance must not be redirected away from its unarchive control.
test("an explicit archived pubkey stays exact with a running sibling", () => {
// Archive state does not change what identity an explicit target names.
const archivedDirect = agent({ pubkey: ARCHIVED_PK, status: "stopped" });
const live = agent({ pubkey: LIVE_PK, status: "running" });

const resolved = resolveCanonicalManagedAgent({
directManagedAgent: archivedDirect,
isArchived: (pubkey) => pubkey === ARCHIVED_PK,
personaInstances: [archivedDirect, live],
preferDirectManagedAgent: true,
preserveRequestedInstance: false,
pubkey: ARCHIVED_PK,
});

assert.equal(resolved, archivedDirect);
});

for (const instances of [[], [agent({ status: "running" })]]) {
test(`relay-only A never resolves to local sibling B (${instances.length} siblings)`, () => {
assert.equal(
resolveCanonicalManagedAgent({
directManagedAgent: undefined,
isArchived: NONE_ARCHIVED,
personaInstances: instances,
pubkey: HISTORICAL_PK,
}),
undefined,
);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
let renderHook;
let cleanup;
let createElement;
let QueryClient;
let QueryClientProvider;
let useCanonicalManagedAgentProfile;
const clients = [];
const A = "a".repeat(64);
const B = "b".repeat(64);
const sibling = {
pubkey: B,
personaId: "shared-persona",
status: "running",
name: "Local B",
};

before(async () => {
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
IS_REACT_ACT_ENVIRONMENT: true,
});
({ renderHook, cleanup } = await import("@testing-library/react"));
({ createElement } = await import("react"));
({ QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
));
({ useCanonicalManagedAgentProfile } = await import(
"./useCanonicalManagedAgentProfile.ts"
));
});
afterEach(() => {
cleanup();
for (const client of clients.splice(0)) client.clear();
});
after(() => dom.window.close());

function wrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
client.setQueryData(["identity"], { pubkey: "c".repeat(64) });
client.setQueryData(["archivedIdentities"], { archived: [] });
clients.push(client);
return ({ children }) =>
createElement(QueryClientProvider, { client }, children);
}

for (const managedAgents of [[], [sibling]]) {
test(`explicit remote A cannot borrow persona P or its ${managedAgents.length} local instances`, () => {
const { result, rerender } = renderHook(
(props) => useCanonicalManagedAgentProfile(props),
{
wrapper: wrapper(),
initialProps: { managedAgents, personaId: "shared-persona", pubkey: A },
},
);
assert.equal(result.current.managedAgent, undefined);
assert.equal(result.current.linkedPersonaId, undefined);
assert.deepEqual(result.current.instanceBuckets, {
live: [],
archived: [],
});
// Only deliberately navigating to the persona may choose B or offer Start.
rerender({ managedAgents, personaId: "shared-persona", pubkey: undefined });
assert.equal(result.current.linkedPersonaId, "shared-persona");
assert.equal(result.current.managedAgent, managedAgents[0]);
// Returning to the explicit key cannot retain the persona representative.
rerender({ managedAgents, personaId: "shared-persona", pubkey: A });
assert.equal(result.current.managedAgent, undefined);
assert.equal(result.current.linkedPersonaId, undefined);
});
}

test("explicit local instance uses only its own definition link, with normalized key matching", () => {
const { result } = renderHook(
() =>
useCanonicalManagedAgentProfile({
managedAgents: [sibling],
personaId: "unrelated-persona",
pubkey: B.toUpperCase(),
}),
{ wrapper: wrapper() },
);
assert.equal(result.current.managedAgent, sibling);
assert.equal(result.current.linkedPersonaId, sibling.personaId);
});
Loading
Loading