mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): keep explicit agent profiles bound to their exact key (#7131)
🤖
## Summary
In Buzz Desktop, clicking a message from stopped agent A could open
running agent B—and B's controls—because both shared a persona (an agent
definition). This now opens the author you clicked and only that agent's
own controls, so you can inspect an old message without being redirected
to a different running agent.
An explicit public key—the identifier for one agent—now stays exact
across message authors, members, DMs, deep links and Instances rows,
including stopped, archived and relay-only agents. Local controls come
only from a matching local record for that key. A relay-only A cannot
borrow B's Start/Stop/Edit controls or configuration.
Deliberately opening a **persona** is different: it can still select a
representative that respects archived instances or offer Start when none
remains. The change removes competing historical-persona redirects
rather than adding another identity exception.
### Related issue
Independent base: `main`; no stack parent or child among the
replacements. Extracted from
[#7114](https://github.com/block/buzz/pull/7114), retained as historical
source (`98fe33ec`).
[Behavior
contract](9c4b6523ce/docs/agent-profile-identity.md).
Originating [Buzz
discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848)
· channel `f7a9536a-1738-4bad-a888-b3ea25010ef1`.
### Testing
Synthetic Playwright mock-bridge state. After screenshots exercise this
independent profile extraction (`df6612b1`); no availability or
cloud-marker implementation is included.
#### Before: historical A redirects to running B
Unchanged main product code (`bc006f67`) with the same updated
historical-message fixture fails: clicking Earlier Parity Agent opens
Current Parity Agent and its Stop control.

#### After: historical A opens A
The clicked author remains Earlier Parity Agent, with A's public key and
its own Start control. The current sibling is not substituted.

#### Exact relay-only A while local sibling B exists
A's public key and owner-scoped profile are visible; no local
Start/Stop/Edit/Add control or sibling definition is borrowed.

#### Explicit persona navigation may select local B
Deliberately opening the persona selects its local representative, with
B's key and legitimate Stop/Restart/Edit controls.

#### Explicit persona without an instance may offer Start
This is a deliberately opened persona, not a relay-only key turned into
a persona surface.

[Original screenshot
publication](https://github.com/block/buzz/pull/7131#issuecomment-5482310890);
all five immutable image URLs and captions retained here. The final
documentation-only commit does not change this UI. These are synthetic
browser fixtures, not live runtime health evidence.
To check manually, open an old message from stopped A while same-persona
B is running; compare the displayed key and controls. Then open the
persona itself and verify that representative selection still works.
#### Evidence and limitations
**5,793 desktop tests**, **56 profile/archive browser cases**,
type/static/size checks and repository-wide `just ci` passed. The
historical-message regression fails on unchanged main by opening B
instead of A. [Published-head CI
passed](https://github.com/block/buzz/actions/runs/33422207592).
The [advisory security
check](https://github.com/block/buzz/actions/runs/33422240973) timed out
without a result; it is not a passing check. No availability,
cloud-marker, discovery or mention-routing change is included. These
screenshots do not establish remote delivery, agent execution or
termination.
#### Security authorization history (audit, not clearance)
The [security
gate](https://github.com/block/buzz/pull/7131#issuecomment-5482300054)
remains visible and unresolved. Existing authorization-request comments
were posted by `loganj`: [old-head
request](https://github.com/block/buzz/pull/7131#issuecomment-5482311163)
for `df6612b1db5a6f8d128cef955fd66a80b6828cb8` at 2026-08-31 17:55:11
UTC, then [current-head
request](https://github.com/block/buzz/pull/7131#issuecomment-5482320424)
for `9c4b6523ceaef0f3d92906fcdb5d9a3b9ede7e17` at 17:55:57 UTC. The
existing [issue-comment workflow
run](https://github.com/block/buzz/actions/runs/33422240973) ended
cancelled after the previously reported timeout; it did not produce a
completed security review. Latest exact-head Run/Post Codex jobs are
skipped, not security approval. Historical comments remain available at
their original links; consolidating their audit here does not withdraw
authorization or clear the gate. An authorized security workflow owner
must arrange the missing exact-range result.
---------
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -27,6 +27,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -75,15 +69,13 @@ 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" });
|
||||
|
||||
@@ -91,16 +83,14 @@ test("a preserved requested instance pins the exact record over canonicalization
|
||||
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" });
|
||||
|
||||
@@ -108,15 +98,13 @@ test("a non-archived historical pubkey canonicalizes to the live persona instanc
|
||||
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" });
|
||||
@@ -130,17 +118,14 @@ 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" });
|
||||
|
||||
@@ -148,10 +133,22 @@ test("an explicit archived pubkey stays exact even with preferDirectManagedAgent
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,66 +1,25 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
pickDirectProfileAgent,
|
||||
pickProfileAgent,
|
||||
} from "@/features/agents/lib/pickProfileAgent";
|
||||
import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
|
||||
import { useOwnedManagedAgentPersonaId } from "@/features/profile/lib/useOwnedManagedAgentPersonaId";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
/**
|
||||
* Resolve the single managed instance a profile surface represents, honouring
|
||||
* the archive-aware target-provenance rules. Pure so the resolution matrix is
|
||||
* testable without mounting the panel; the hook supplies the live inputs.
|
||||
*
|
||||
* - `preserveRequestedInstance` + a direct match pins that exact record (an
|
||||
* explicit Runtime → Instances selection).
|
||||
* - A deliberately requested archived pubkey stays EXACT — exactness beats
|
||||
* canonicalization iff the requested pubkey is archived — so its archive
|
||||
* controller can unarchive that identity even when a live sibling exists.
|
||||
* Returns the managed record when one exists; otherwise `undefined`, so the
|
||||
* panel falls back to the requested pubkey verbatim (a historical archived
|
||||
* key with no current managed record still resolves to itself).
|
||||
* - `preferDirectManagedAgent` (the panel's own default) keeps a directly
|
||||
* opened active instance exact so an access edit targets it, only redirecting
|
||||
* an inactive click to a live sibling — see `pickDirectProfileAgent`.
|
||||
* - Otherwise persona-target and non-archived historical navigation resolve
|
||||
* through the shared archive-aware selector: all instances archived yields
|
||||
* `undefined` (persona-only mode), else the canonical live instance.
|
||||
* An explicit public key always names exactly that identity, whether active,
|
||||
* stopped, archived, or absent from this device's managed inventory. Only a
|
||||
* persona target may choose an archive-aware representative. A persona link is
|
||||
* not an identity alias and never grants local management of a different key.
|
||||
*/
|
||||
export function resolveCanonicalManagedAgent(input: {
|
||||
directManagedAgent: ManagedAgent | undefined;
|
||||
isArchived: (pubkey: string) => boolean;
|
||||
personaInstances: readonly ManagedAgent[];
|
||||
preferDirectManagedAgent: boolean;
|
||||
preserveRequestedInstance: boolean;
|
||||
pubkey: string | undefined;
|
||||
}): ManagedAgent | undefined {
|
||||
const {
|
||||
directManagedAgent,
|
||||
isArchived,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
} = input;
|
||||
if (preserveRequestedInstance && directManagedAgent) {
|
||||
return directManagedAgent;
|
||||
}
|
||||
if (pubkey && isArchived(pubkey)) {
|
||||
return directManagedAgent;
|
||||
}
|
||||
if (preferDirectManagedAgent && directManagedAgent) {
|
||||
return pickDirectProfileAgent(
|
||||
directManagedAgent,
|
||||
personaInstances,
|
||||
isArchived,
|
||||
);
|
||||
}
|
||||
return pickProfileAgent(personaInstances, isArchived) ?? directManagedAgent;
|
||||
const { directManagedAgent, isArchived, personaInstances, pubkey } = input;
|
||||
if (pubkey) return directManagedAgent;
|
||||
return pickProfileAgent(personaInstances, isArchived);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,21 +41,11 @@ export function bucketPersonaInstances(
|
||||
}
|
||||
|
||||
export function useCanonicalManagedAgentProfile(input: {
|
||||
currentPubkey: string | undefined;
|
||||
managedAgents: readonly ManagedAgent[] | undefined;
|
||||
personaId: string | undefined;
|
||||
preferDirectManagedAgent?: boolean;
|
||||
preserveRequestedInstance?: boolean;
|
||||
pubkey: string | undefined;
|
||||
}) {
|
||||
const {
|
||||
currentPubkey,
|
||||
managedAgents,
|
||||
personaId,
|
||||
preferDirectManagedAgent = false,
|
||||
preserveRequestedInstance = false,
|
||||
pubkey,
|
||||
} = input;
|
||||
const { managedAgents, personaId, pubkey } = input;
|
||||
const directManagedAgent = React.useMemo(() => {
|
||||
if (!pubkey) return undefined;
|
||||
const target = normalizePubkey(pubkey);
|
||||
@@ -104,18 +53,9 @@ export function useCanonicalManagedAgentProfile(input: {
|
||||
(agent) => normalizePubkey(agent.pubkey) === target,
|
||||
);
|
||||
}, [managedAgents, pubkey]);
|
||||
const requestedProfileQuery = useUserProfileQuery(pubkey);
|
||||
const historicalPersonaId = useOwnedManagedAgentPersonaId({
|
||||
agentPubkey: pubkey,
|
||||
enabled: Boolean(
|
||||
pubkey &&
|
||||
!directManagedAgent &&
|
||||
ownsAuthorAgent(requestedProfileQuery.data, currentPubkey),
|
||||
),
|
||||
ownerPubkey: currentPubkey,
|
||||
});
|
||||
const linkedPersonaId =
|
||||
personaId ?? directManagedAgent?.personaId ?? historicalPersonaId;
|
||||
// Explicit identity targets can use only their own local definition link.
|
||||
// Relay-only identities must not inherit a local persona's Start/Edit actions.
|
||||
const linkedPersonaId = pubkey ? directManagedAgent?.personaId : personaId;
|
||||
const personaInstances = React.useMemo(() => {
|
||||
if (!linkedPersonaId) {
|
||||
return directManagedAgent ? [directManagedAgent] : [];
|
||||
@@ -131,18 +71,9 @@ export function useCanonicalManagedAgentProfile(input: {
|
||||
directManagedAgent,
|
||||
isArchived,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
}),
|
||||
[
|
||||
directManagedAgent,
|
||||
isArchived,
|
||||
personaInstances,
|
||||
preferDirectManagedAgent,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
],
|
||||
[directManagedAgent, isArchived, personaInstances, pubkey],
|
||||
);
|
||||
// Split the roster for the Instances list off the same predicate the selector
|
||||
// uses — see `bucketPersonaInstances` for the fail-open semantics.
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { finalizeEvent, getPublicKey } from "nostr-tools/pure";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
KIND_IA_ARCHIVE_REQUEST,
|
||||
KIND_MANAGED_AGENT,
|
||||
} from "@/shared/constants/kinds";
|
||||
import {
|
||||
personaIdFromOwnedManagedAgentArchive,
|
||||
personaIdFromOwnedManagedAgentEvent,
|
||||
useOwnedManagedAgentPersonaId,
|
||||
} from "./useOwnedManagedAgentPersonaId.ts";
|
||||
|
||||
const OWNER_SECRET = new Uint8Array(32);
|
||||
OWNER_SECRET[31] = 1;
|
||||
const OTHER_SECRET = new Uint8Array(32);
|
||||
OTHER_SECRET[31] = 2;
|
||||
const OWNER = getPublicKey(OWNER_SECRET);
|
||||
const AGENT = "a".repeat(64);
|
||||
|
||||
function managedAgentEvent({
|
||||
agentPubkey = AGENT,
|
||||
content = JSON.stringify({ persona_id: "persona-reviewer" }),
|
||||
secret = OWNER_SECRET,
|
||||
} = {}) {
|
||||
return finalizeEvent(
|
||||
{
|
||||
created_at: 1,
|
||||
kind: KIND_MANAGED_AGENT,
|
||||
tags: [["d", agentPubkey]],
|
||||
content,
|
||||
},
|
||||
secret,
|
||||
);
|
||||
}
|
||||
|
||||
function managedAgentArchive({
|
||||
agentPubkey = AGENT,
|
||||
personaId = "persona-reviewer",
|
||||
secret = OWNER_SECRET,
|
||||
} = {}) {
|
||||
return finalizeEvent(
|
||||
{
|
||||
created_at: 2,
|
||||
kind: KIND_IA_ARCHIVE_REQUEST,
|
||||
tags: [["p", agentPubkey]],
|
||||
content: JSON.stringify({ persona_id: personaId }),
|
||||
},
|
||||
secret,
|
||||
);
|
||||
}
|
||||
|
||||
test("resolves an owner-signed historical agent key to its persona", () => {
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentEvent(managedAgentEvent(), OWNER, AGENT),
|
||||
"persona-reviewer",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves a deleted historical agent key from its owner-signed archive request", () => {
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentArchive(managedAgentArchive(), OWNER, AGENT),
|
||||
"persona-reviewer",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects archive aliases with the wrong owner or target", () => {
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentArchive(
|
||||
managedAgentArchive({ secret: OTHER_SECRET }),
|
||||
OWNER,
|
||||
AGENT,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentArchive(
|
||||
managedAgentArchive({ agentPubkey: "b".repeat(64) }),
|
||||
OWNER,
|
||||
AGENT,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a managed-agent event from a different owner", () => {
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentEvent(
|
||||
managedAgentEvent({ secret: OTHER_SECRET }),
|
||||
OWNER,
|
||||
AGENT,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects a managed-agent event for a different agent key", () => {
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentEvent(
|
||||
managedAgentEvent({ agentPubkey: "b".repeat(64) }),
|
||||
OWNER,
|
||||
AGENT,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects empty or malformed persona ids", () => {
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentEvent(
|
||||
managedAgentEvent({ content: JSON.stringify({ persona_id: "" }) }),
|
||||
OWNER,
|
||||
AGENT,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
personaIdFromOwnedManagedAgentEvent(
|
||||
managedAgentEvent({ content: "not-json" }),
|
||||
OWNER,
|
||||
AGENT,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("still resolves the live record when the archive lookup fails", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
window: dom.window,
|
||||
});
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const originalFetchFirstEvent = relayClient.fetchFirstEvent;
|
||||
relayClient.fetchFirstEvent = async (filter) => {
|
||||
if (filter.kinds?.includes(KIND_IA_ARCHIVE_REQUEST)) {
|
||||
throw new Error("archive lookup unavailable");
|
||||
}
|
||||
return managedAgentEvent();
|
||||
};
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() =>
|
||||
useOwnedManagedAgentPersonaId({
|
||||
agentPubkey: AGENT,
|
||||
enabled: true,
|
||||
ownerPubkey: OWNER,
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.equal(result.current, "persona-reviewer");
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchFirstEvent = originalFetchFirstEvent;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not expose a persona result for stale lookup inputs", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
window: dom.window,
|
||||
});
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const originalFetchFirstEvent = relayClient.fetchFirstEvent;
|
||||
relayClient.fetchFirstEvent = async (filter) =>
|
||||
filter.kinds?.includes(KIND_MANAGED_AGENT) ? managedAgentEvent() : null;
|
||||
const observed = [];
|
||||
|
||||
try {
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
(props) => {
|
||||
const personaId = useOwnedManagedAgentPersonaId(props);
|
||||
observed.push(personaId);
|
||||
return personaId;
|
||||
},
|
||||
{
|
||||
initialProps: {
|
||||
agentPubkey: AGENT,
|
||||
enabled: true,
|
||||
ownerPubkey: OWNER,
|
||||
},
|
||||
},
|
||||
);
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
assert.equal(result.current, "persona-reviewer");
|
||||
|
||||
const switchIndex = observed.length;
|
||||
rerender({
|
||||
agentPubkey: "b".repeat(64),
|
||||
enabled: false,
|
||||
ownerPubkey: OWNER,
|
||||
});
|
||||
assert.deepEqual(observed.slice(switchIndex), [null]);
|
||||
assert.equal(result.current, null);
|
||||
unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
relayClient.fetchFirstEvent = originalFetchFirstEvent;
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { verifyEvent } from "nostr-tools/pure";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
KIND_IA_ARCHIVE_REQUEST,
|
||||
KIND_MANAGED_AGENT,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
type ManagedAgentEventContent = {
|
||||
persona_id?: unknown;
|
||||
};
|
||||
|
||||
function eventHasValidSignature(event: RelayEvent): boolean {
|
||||
try {
|
||||
return verifyEvent({
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
kind: event.kind,
|
||||
tags: event.tags,
|
||||
content: event.content,
|
||||
sig: event.sig,
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function personaIdFromEventContent(event: RelayEvent): string | null {
|
||||
try {
|
||||
const content = JSON.parse(event.content) as ManagedAgentEventContent;
|
||||
return typeof content.persona_id === "string" &&
|
||||
content.persona_id.trim().length > 0
|
||||
? content.persona_id
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function personaIdFromOwnedManagedAgentEvent(
|
||||
event: RelayEvent | null,
|
||||
ownerPubkey: string,
|
||||
agentPubkey: string,
|
||||
): string | null {
|
||||
if (!event || event.kind !== KIND_MANAGED_AGENT) return null;
|
||||
|
||||
const owner = normalizePubkey(ownerPubkey);
|
||||
const agent = normalizePubkey(agentPubkey);
|
||||
if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null;
|
||||
if (
|
||||
!event.tags.some(
|
||||
(tag) => tag[0] === "d" && normalizePubkey(tag[1] ?? "") === agent,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!eventHasValidSignature(event)) return null;
|
||||
|
||||
return personaIdFromEventContent(event);
|
||||
}
|
||||
|
||||
export function personaIdFromOwnedManagedAgentArchive(
|
||||
event: RelayEvent | null,
|
||||
ownerPubkey: string,
|
||||
agentPubkey: string,
|
||||
): string | null {
|
||||
if (!event || event.kind !== KIND_IA_ARCHIVE_REQUEST) return null;
|
||||
|
||||
const owner = normalizePubkey(ownerPubkey);
|
||||
const agent = normalizePubkey(agentPubkey);
|
||||
if (!owner || !agent || normalizePubkey(event.pubkey) !== owner) return null;
|
||||
if (
|
||||
!event.tags.some(
|
||||
(tag) => tag[0] === "p" && normalizePubkey(tag[1] ?? "") === agent,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!eventHasValidSignature(event)) return null;
|
||||
|
||||
return personaIdFromEventContent(event);
|
||||
}
|
||||
|
||||
type PersonaLookupResult = {
|
||||
key: string;
|
||||
personaId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve an owned historical agent key back to its persona. The live
|
||||
* kind:30177 projection provides the alias before deletion; the owner-signed
|
||||
* NIP-IA archive request preserves it after the projection is tombstoned.
|
||||
*/
|
||||
export function useOwnedManagedAgentPersonaId(input: {
|
||||
agentPubkey: string | undefined;
|
||||
enabled: boolean;
|
||||
ownerPubkey: string | undefined;
|
||||
}): string | null {
|
||||
const { agentPubkey, enabled, ownerPubkey } = input;
|
||||
const [result, setResult] = React.useState<PersonaLookupResult | null>(null);
|
||||
const normalizedOwner = normalizePubkey(ownerPubkey ?? "");
|
||||
const normalizedAgent = normalizePubkey(agentPubkey ?? "");
|
||||
const lookupKey =
|
||||
enabled && normalizedOwner && normalizedAgent
|
||||
? `${normalizedOwner}:${normalizedAgent}`
|
||||
: null;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!lookupKey) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void Promise.allSettled([
|
||||
relayClient.fetchFirstEvent({
|
||||
kinds: [KIND_MANAGED_AGENT],
|
||||
authors: [normalizedOwner],
|
||||
"#d": [normalizedAgent],
|
||||
limit: 1,
|
||||
}),
|
||||
relayClient.fetchFirstEvent({
|
||||
kinds: [KIND_IA_ARCHIVE_REQUEST],
|
||||
authors: [normalizedOwner],
|
||||
"#p": [normalizedAgent],
|
||||
limit: 1,
|
||||
}),
|
||||
]).then(([managedAgentResult, archiveResult]) => {
|
||||
if (cancelled) return;
|
||||
const managedAgentEvent =
|
||||
managedAgentResult.status === "fulfilled"
|
||||
? managedAgentResult.value
|
||||
: null;
|
||||
const archiveEvent =
|
||||
archiveResult.status === "fulfilled" ? archiveResult.value : null;
|
||||
setResult({
|
||||
key: lookupKey,
|
||||
personaId:
|
||||
personaIdFromOwnedManagedAgentEvent(
|
||||
managedAgentEvent,
|
||||
normalizedOwner,
|
||||
normalizedAgent,
|
||||
) ??
|
||||
personaIdFromOwnedManagedAgentArchive(
|
||||
archiveEvent,
|
||||
normalizedOwner,
|
||||
normalizedAgent,
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lookupKey, normalizedAgent, normalizedOwner]);
|
||||
|
||||
return result?.key === lookupKey ? result.personaId : null;
|
||||
}
|
||||
@@ -191,14 +191,12 @@ export function UserProfilePanel({
|
||||
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
|
||||
const { instanceBuckets, linkedPersonaId, managedAgent } =
|
||||
useCanonicalManagedAgentProfile({
|
||||
currentPubkey,
|
||||
managedAgents: managedAgentsQuery.data,
|
||||
personaId: persona?.id,
|
||||
preferDirectManagedAgent: true,
|
||||
preserveRequestedInstance,
|
||||
pubkey,
|
||||
});
|
||||
const resolvedPersonaFromSource = React.useMemo(() => {
|
||||
if (pubkey && !managedAgent) return undefined;
|
||||
const personaId = linkedPersonaId ?? managedAgent?.personaId;
|
||||
if (personaId) {
|
||||
const refreshedPersona = personasQuery.data?.find(
|
||||
@@ -208,7 +206,7 @@ export function UserProfilePanel({
|
||||
return refreshedPersona;
|
||||
}
|
||||
}
|
||||
if (persona) {
|
||||
if (!pubkey && persona) {
|
||||
return persona;
|
||||
}
|
||||
if (!managedAgent?.personaId) {
|
||||
@@ -217,14 +215,15 @@ export function UserProfilePanel({
|
||||
return personasQuery.data?.find(
|
||||
(candidate) => candidate.id === managedAgent.personaId,
|
||||
);
|
||||
}, [linkedPersonaId, managedAgent?.personaId, persona, personasQuery.data]);
|
||||
}, [linkedPersonaId, managedAgent, persona, personasQuery.data, pubkey]);
|
||||
const profileIdentityKey =
|
||||
managedAgent?.pubkey ?? pubkey ?? `persona:${persona?.id ?? "unknown"}`;
|
||||
const resolvedPersona = useRetainedPersona(
|
||||
pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`;
|
||||
const retainedPersona = useRetainedPersona(
|
||||
resolvedPersonaFromSource,
|
||||
profileIdentityKey,
|
||||
);
|
||||
const effectivePubkey = managedAgent?.pubkey ?? pubkey ?? null;
|
||||
const resolvedPersona = pubkey && !managedAgent ? undefined : retainedPersona;
|
||||
const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null;
|
||||
const pubkeyLower = effectivePubkey?.toLowerCase() ?? "";
|
||||
|
||||
const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined);
|
||||
@@ -331,6 +330,7 @@ export function UserProfilePanel({
|
||||
const canOpenAgentLogs =
|
||||
isOwner === true && managedAgent?.backend.type === "local";
|
||||
const canInstantiateAgent =
|
||||
!pubkey &&
|
||||
isOwner === true &&
|
||||
resolvedPersona !== undefined &&
|
||||
managedAgent === undefined;
|
||||
|
||||
138
desktop/tests/e2e/exact-key-profile.spec.ts
Normal file
138
desktop/tests/e2e/exact-key-profile.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
const REMOTE = TEST_IDENTITIES.charlie.pubkey;
|
||||
const LOCAL = "d".repeat(64);
|
||||
const OWNER = "deadbeef".repeat(8);
|
||||
const PERSONA = "shared-persona";
|
||||
|
||||
for (const hasSibling of [false, true]) {
|
||||
test(`explicit relay-only identity has no local controls (${hasSibling ? "local sibling" : "persona only"})`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installMockBridge(page, {
|
||||
oaOwnerIsMe: true,
|
||||
managedAgents: hasSibling
|
||||
? [
|
||||
{
|
||||
pubkey: LOCAL,
|
||||
name: "Local sibling B",
|
||||
personaId: PERSONA,
|
||||
status: "running",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
personas: [
|
||||
{
|
||||
id: PERSONA,
|
||||
displayName: "Shared persona P",
|
||||
isActive: true,
|
||||
systemPrompt: "Local definition, not the remote identity.",
|
||||
},
|
||||
],
|
||||
searchProfiles: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
displayName: "Relay agent A",
|
||||
ownerPubkey: OWNER,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto(`/#/agents?profile=${REMOTE}`);
|
||||
const panel = page.getByTestId("user-profile-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-name-row")).toContainText(
|
||||
"Relay agent A",
|
||||
);
|
||||
for (const testId of [
|
||||
"user-profile-agent-primary-action",
|
||||
"user-profile-start-agent",
|
||||
"user-profile-edit-agent",
|
||||
"user-profile-add-to-channel",
|
||||
]) {
|
||||
await expect(page.getByTestId(testId)).toHaveCount(0);
|
||||
}
|
||||
await expect(panel).not.toContainText(
|
||||
"Local definition, not the remote identity.",
|
||||
);
|
||||
await waitForAnimations(page);
|
||||
await panel.screenshot({
|
||||
path: testInfo.outputPath("exact-relay-identity.png"),
|
||||
});
|
||||
|
||||
// Persona-only navigation remains legitimate and intentionally different.
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
await page.getByTestId(`persona-agent-row-${PERSONA}`).click();
|
||||
await expect(
|
||||
page.getByTestId(
|
||||
hasSibling
|
||||
? "user-profile-agent-primary-action"
|
||||
: "user-profile-start-agent",
|
||||
),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await panel.screenshot({
|
||||
path: testInfo.outputPath("explicit-persona.png"),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const allArchived of [false, true]) {
|
||||
test(`archived exact key stays navigable (${allArchived ? "all archived" : "live sibling"})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
oaOwnerIsMe: true,
|
||||
archivedIdentities: allArchived ? [REMOTE, LOCAL] : [REMOTE],
|
||||
managedAgents: [
|
||||
{
|
||||
pubkey: REMOTE,
|
||||
name: "Archived A",
|
||||
personaId: PERSONA,
|
||||
status: "stopped",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
{
|
||||
pubkey: LOCAL,
|
||||
name: "Sibling B",
|
||||
personaId: PERSONA,
|
||||
status: "running",
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
],
|
||||
personas: [
|
||||
{
|
||||
id: PERSONA,
|
||||
displayName: "Shared persona P",
|
||||
isActive: true,
|
||||
systemPrompt: "Archive profile fixture.",
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto(`/#/agents?profile=${REMOTE}`);
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("user-profile-agent-primary-action"),
|
||||
).toHaveAttribute("aria-label", "Start agent");
|
||||
await expect(page.getByTestId("user-profile-name-row")).toContainText(
|
||||
"Archived A",
|
||||
);
|
||||
// Persona navigation still excludes archived representatives.
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
await page.getByTestId(`persona-agent-row-${PERSONA}`).click();
|
||||
if (allArchived) {
|
||||
await expect(page.getByTestId("user-profile-start-agent")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("user-profile-agent-primary-action"),
|
||||
).toHaveCount(0);
|
||||
} else {
|
||||
await expect(
|
||||
page.getByTestId("user-profile-agent-primary-action"),
|
||||
).toHaveAttribute("aria-label", "Stop");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1169,6 +1169,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a
|
||||
await expect(page.getByTestId("user-profile-message")).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-huddle")).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-profile-wave")).toHaveCount(0);
|
||||
await expectHashSearchParam(page, "profile", agentPubkey);
|
||||
const agentPresenceBadge = page.getByTestId("user-profile-presence-badge");
|
||||
await expect(agentPresenceBadge).toBeVisible();
|
||||
await expect(agentPresenceBadge).toHaveAttribute("aria-label", "Online");
|
||||
@@ -1234,6 +1235,7 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a
|
||||
await agentPrimaryAction.click();
|
||||
await expect(agentPrimaryAction).toHaveAttribute("aria-label", "Start agent");
|
||||
await expect(agentPresenceBadge).toHaveAttribute("aria-label", "Offline");
|
||||
await expectHashSearchParam(page, "profile", agentPubkey);
|
||||
await expect(agentPrimaryAction).toHaveClass(/bg-foreground/);
|
||||
await expect(agentPrimaryAction).toHaveClass(/text-background/);
|
||||
await expect(page.getByTestId("user-profile-agent-restart")).toHaveCount(0);
|
||||
@@ -1834,9 +1836,9 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a
|
||||
await expect(page.getByTestId("agent-memory-list")).toContainText("orphan");
|
||||
});
|
||||
|
||||
test("an older agent message opens the same persona instance as the Agents library", async ({
|
||||
test("an older agent message stays exact while persona navigation selects the live instance", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
const personaId = "profile-parity-agent";
|
||||
const historicalPubkey = TEST_IDENTITIES.charlie.pubkey;
|
||||
const currentPubkey = "d".repeat(64);
|
||||
@@ -1875,7 +1877,6 @@ test("an older agent message opens the same persona instance as the Agents libra
|
||||
await expect(
|
||||
page.getByTestId("user-profile-agent-primary-action"),
|
||||
).toHaveAttribute("aria-label", "Stop");
|
||||
const agentsLibraryContract = await readOwnedAgentProfileContract(page);
|
||||
|
||||
await page.getByTestId("user-profile-tab-runtime").click();
|
||||
await page.getByTestId("user-profile-instances").click();
|
||||
@@ -1889,6 +1890,8 @@ test("an older agent message opens the same persona instance as the Agents libra
|
||||
page.getByTestId(`user-profile-instance-${historicalPubkey}`),
|
||||
).toContainText("Current");
|
||||
|
||||
const exactInstanceContract = await readOwnedAgentProfileContract(page);
|
||||
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
await page.getByTestId("channel-agents").click();
|
||||
const historicalMessage = page
|
||||
@@ -1898,10 +1901,15 @@ test("an older agent message opens the same persona instance as the Agents libra
|
||||
await historicalMessage.locator("button").first().click();
|
||||
await expect(
|
||||
page.getByTestId("user-profile-agent-primary-action"),
|
||||
).toHaveAttribute("aria-label", "Stop");
|
||||
).toHaveAttribute("aria-label", "Start agent");
|
||||
const messageContract = await readOwnedAgentProfileContract(page);
|
||||
|
||||
expect(messageContract).toEqual(agentsLibraryContract);
|
||||
expect(messageContract).toEqual(exactInstanceContract);
|
||||
await page.getByTestId("user-profile-tab-info").click();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("historical-exact-instance.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("restored Inbox deep link hides the back arrow", async ({ page }) => {
|
||||
|
||||
41
docs/agent-profile-identity.md
Normal file
41
docs/agent-profile-identity.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Agent profile identity
|
||||
|
||||
## An explicit key is exact
|
||||
|
||||
Opening a public key from a message, member, DM, deep link, or Instances row
|
||||
always opens that identity. Active, stopped, archived, and relay-only keys obey
|
||||
the same rule. A local managed record may supply controls only for that exact
|
||||
key. An owner-signed kind 30177 `persona_id` (or an archive request's historical
|
||||
persona link) is **not** an identity alias and does not grant access to a local
|
||||
sibling's controls, definition, configuration, or Start action.
|
||||
|
||||
Only explicit persona navigation (for example, the persona card in My Agents)
|
||||
selects an archive-aware representative using `pickProfileAgent`. With no live
|
||||
representative, it remains a persona-only surface and may offer Start. An
|
||||
explicit relay-only key must not turn into that persona-only surface, even if a
|
||||
matching definition exists locally. There is no synthetic/secretless
|
||||
`ManagedAgent` record.
|
||||
|
||||
This intentionally supersedes the old historical-message redirect: a message
|
||||
from stopped A no longer opens running B merely because they share persona P.
|
||||
The requested key takes precedence even if the caller also supplies persona
|
||||
context. Local instance profiles may still show their own linked definition and
|
||||
an explicitly navigable Instances list. Ownership alone permits owner-scoped
|
||||
relay reads, not local management.
|
||||
|
||||
Implementation: `useCanonicalManagedAgentProfile` and `UserProfilePanel`.
|
||||
The obsolete historical-persona relay lookup and inactive-instance redirect
|
||||
helper have been removed instead of adding another exception flag.
|
||||
|
||||
## Regression gates
|
||||
|
||||
- `profile/lib/resolveCanonicalManagedAgent.test.mjs`: exact identity across
|
||||
active/stopped/archived/relay-only keys, and persona representative selection.
|
||||
- `profile/lib/useCanonicalManagedAgentProfile.test.mjs`: remote A cannot borrow
|
||||
persona P/local B, including persona-only navigation and returning to A.
|
||||
- `tests/e2e/exact-key-profile.spec.ts`: relay-only profile controls versus
|
||||
explicit persona navigation, with and without a local sibling; exact archived
|
||||
keys with a live sibling and persona-only navigation when all are archived.
|
||||
- `tests/e2e/profile.spec.ts`: historical messages match the exact Instances
|
||||
selection rather than the current persona card; profile ingress parity.
|
||||
- `tests/e2e/identity-archive.spec.ts`: archive/unarchive authority and flair.
|
||||
Reference in New Issue
Block a user