mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
## Summary Mobile kept offering archived people and agents as choices: Add members (including adding an agent to a Solo chat), New DM, the `@` mention chooser, Search › People and the Agents page. An archived agent that shares a live agent's name also forced a qualifier onto the live one, because names were told apart across both. The members sheet listed archived members among live ones with nothing to mark them. Now, matching desktop: - Archived people and agents are left out of those choices. History (messages, activity, profiles) still shows them. - The members sheet moves archived members out of People and Agents into their own "Archived · N" section after both: archived people, then archived agents. Each row reads "Archived · Member" (or Agent) in place of the role, status or "Working…" line. A screen reader reads it with the name. The members preview on the channel info page (the first five members above "See all") follows the same order and label. - Nothing is hidden or marked while the community's archive list is unknown or cannot be read, and you are never hidden from yourself. ### Related issue None found. The desktop members dialog gets the same Archived section in https://github.com/block/buzz-app/pull/3203. ### Testing Widget and provider tests cover each surface, the unknown-archive case, the viewer rule and a community switch. Each test fails without its change. Android emulator, local relay, fictional names. Each archived person or agent has a live namesake. History rows still show archived identities, as intended. **`@` mention chooser, typing "@Sc" (Scout `qsfn` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/6693729c-66c1-4433-8a7b-4f0e948057ee" width="320"> | <img src="https://github.com/user-attachments/assets/eb4f80be-780e-4dbe-8b2b-e6a5b6c3ca97" width="320"> | **`@` mention chooser, typing "@Ca" (Casey Lee `9mhy` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/b6d409cd-7af1-4da7-b5b9-deece5e21de5" width="320"> | <img src="https://github.com/user-attachments/assets/60e21387-791b-4233-b8e6-1a29f94e6810" width="320"> | **Add members, searching "Morgan" (Morgan Reyes `ssc2` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/1222a923-5e43-4263-843d-57564bdc7af3" width="320"> | <img src="https://github.com/user-attachments/assets/dbe58278-788d-4a89-83bf-f0b4bc119c5e" width="320"> | **Add members, searching "Atlas" (Atlas `85gl` is archived)** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/55c67a21-4cf5-47c5-b6ac-1ba80d68a3ec" width="320"> | <img src="https://github.com/user-attachments/assets/54b19ab3-fa2f-4933-8b67-1056d6e3b3a4" width="320"> | **New DM, no query yet** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/6a26fd49-4a06-47bd-ba37-37e5898ab344" width="320"> | <img src="https://github.com/user-attachments/assets/3f33eb2b-f27a-4528-91c0-ac76d3fdf44b" width="320"> | **New DM, searching "Morgan"** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/22d2d617-3074-4910-8c39-e0778e47a879" width="320"> | <img src="https://github.com/user-attachments/assets/fa00b160-e350-49af-94d0-beb15c357bef" width="320"> | **Search › People, "Morgan"** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/83a28f2b-9b22-4d0c-8728-8a2dfb10e634" width="320"> | <img src="https://github.com/user-attachments/assets/573aed3f-75ab-478f-8c11-bedab5c6ce4f" width="320"> | **Agents page** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/d7980f95-825c-4d1a-ad67-95f906a52e9a" width="320"> | <img src="https://github.com/user-attachments/assets/6252c38a-b2ec-48dc-ab4e-824ea29bbd85" width="320"> | **Channel info, members preview** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/8744cdfd-c073-4ade-bd53-9d5e137cee60" width="320"> | <img src="https://github.com/user-attachments/assets/5240b1be-13d5-4f22-85a9-8375f1d8ac18" width="320"> | **Members sheet, top** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/c9241478-99b1-452e-ad41-8c38fe080182" width="320"> | <img src="https://github.com/user-attachments/assets/3c6f9247-60c6-47df-94b0-9175f25805bb" width="320"> | **Members sheet, scrolled to the end** | Before | After | | --- | --- | | <img src="https://github.com/user-attachments/assets/7da7fcc8-28ea-4c7a-904e-91ca5a068d23" width="320"> | <img src="https://github.com/user-attachments/assets/024f676e-8150-457d-b7d0-67fa1a680f8d" width="320"> | ## Details - The archive reader (the community's NIP-IA archive list, signed by the relay's NIP-11 `self` key) moves from `shared/me` to `shared/identity_archives`, since it is no longer Solo-only. `hiddenArchivedIdentitiesProvider` is the single rule the surfaces read. - Pulling to refresh on the Agents page also rereads the archive list. Other views reuse the last list, as on desktop; it is also reread on reconnect and when the agent inventory changes. - A refresh keeps the last known list. After a community or account switch, the previous community's list does not apply: nobody is hidden until the new list arrives. - The shared chooser drops archived identities before names are compared, which covers Add members, New DM and mentions. - Search › People asks the relay for 24 people and shows the first 8 that are not archived, so archived matches do not push live people out. - The channel info preview shows the label on one line, so with a long name the role after "Archived" can be cut off. A screen reader still reads it in full. - Not changed: typing a full `@Name` by hand (without the chooser) still resolves against archived channel members too, so an archived namesake can still make a typed name ambiguous. Picking from the chooser is unaffected. Possible follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1241 lines
41 KiB
Dart
1241 lines
41 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:buzz/features/profile/presence_cache_provider.dart';
|
|
import 'package:buzz/shared/agents/remote_agents.dart';
|
|
import 'package:buzz/shared/agents/owned_agent_directory.dart';
|
|
import 'package:buzz/shared/community/community.dart';
|
|
import 'package:buzz/shared/community/community_provider.dart';
|
|
import 'package:buzz/shared/crypto/nip_oa.dart';
|
|
import 'package:buzz/shared/hosted/account_identity_store.dart';
|
|
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
|
import 'package:buzz/shared/hosted/hosted_environment.dart';
|
|
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
|
import 'package:buzz/shared/identity_archives/identity_archives.dart';
|
|
import 'package:buzz/shared/me/me_assistants.dart';
|
|
import 'package:buzz/shared/me/me_sync.dart';
|
|
import 'package:buzz/shared/relay/relay.dart';
|
|
import 'package:buzz/shared/storage/secure_storage.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
import '../../helpers/remote_agents_fixture.dart';
|
|
|
|
final _scope = 'remote-agents:${HostedEnvironment.origin.host}:$fakeSubject';
|
|
|
|
/// Deterministic test keys.
|
|
nostr.Keys _keys(String last) => nostr.Keys('0' * 63 + last);
|
|
|
|
/// Starts signed out, like a cold start, until its saved session is restored.
|
|
class _RestoringRegistry extends FakeRegistry {
|
|
_RestoringRegistry() : super(signedIn: false);
|
|
|
|
@override
|
|
Future<BuilderlabAccount?> restore() async {
|
|
await Future<void>.delayed(Duration.zero);
|
|
signedIn = true;
|
|
return account;
|
|
}
|
|
}
|
|
|
|
Community _community({String? pubkey}) => Community.create(
|
|
name: 'Test',
|
|
relayUrl: 'wss://test.${HostedEnvironment.communitySuffix}',
|
|
pubkey: pubkey ?? fakeOwner.public,
|
|
nsec: fakeOwner.nsec,
|
|
);
|
|
|
|
Future<RelayFakeController> _controller(FakeRegistry registry) async {
|
|
await AccountIdentityStore().forSubject(fakeSubject).create(fakeOwner.nsec);
|
|
final controller = RelayFakeController(registry);
|
|
addTearDown(() {
|
|
controller.dispose();
|
|
controller.relayAuth.dispose();
|
|
registry.dispose();
|
|
});
|
|
return controller;
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
// Registrations go to a real loopback relay. The binding's mocked HTTP
|
|
// client would reject the WebSocket upgrade.
|
|
HttpOverrides.global = null;
|
|
setUp(() {
|
|
FlutterSecureStorage.setMockInitialValues({});
|
|
SharedPreferences.setMockInitialValues({});
|
|
});
|
|
|
|
Future<(RelayFakeController, FakeRegistry)> editableAgent() async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
registry.listed = [
|
|
{
|
|
'agent_pubkey': fakeAgent,
|
|
'agent_name': 'Scout',
|
|
'agent_id': 'agent',
|
|
'status': 2,
|
|
'model_id': 'old-model',
|
|
'reasoning_effort': 'low',
|
|
},
|
|
];
|
|
final controller = await _controller(registry);
|
|
await controller.run(controller.refresh);
|
|
return (controller, registry);
|
|
}
|
|
|
|
Future<bool> saveModel(
|
|
RelayFakeController controller, {
|
|
String effort = 'high',
|
|
bool current = true,
|
|
String? key,
|
|
}) async {
|
|
final model = (await controller.modelCatalog()).first;
|
|
return controller.updateModel(
|
|
community: _community(),
|
|
pubkey: key ?? fakeAgent,
|
|
model: model,
|
|
effort: effort,
|
|
isCurrent: () => current,
|
|
);
|
|
}
|
|
|
|
test(
|
|
'model changes save model and effort and confirm server readback',
|
|
() async {
|
|
final (controller, registry) = await editableAgent();
|
|
expect(await saveModel(controller), isTrue);
|
|
expect(registry.bodies['/v3/beekeeper/update-agent'], {
|
|
'agent_pubkey': fakeAgent,
|
|
'model_id': 'test-model',
|
|
'reasoning_effort': 'high',
|
|
});
|
|
expect(controller.agents.single['model_id'], 'test-model');
|
|
},
|
|
);
|
|
|
|
test(
|
|
'rejected model writes preserve the saved model and surface failure',
|
|
() async {
|
|
final (controller, registry) = await editableAgent();
|
|
registry.failInstructions = true;
|
|
expect(await saveModel(controller), isFalse);
|
|
expect(controller.agents.single['model_id'], 'old-model');
|
|
expect(controller.failure, contains('unavailable'));
|
|
},
|
|
);
|
|
|
|
test('unconfirmed model write does not report success', () async {
|
|
final (controller, registry) = await editableAgent();
|
|
registry.ignoreModelUpdate = true;
|
|
expect(await saveModel(controller), isFalse);
|
|
expect(controller.failure, contains('not confirmed'));
|
|
expect(controller.agents.single['model_id'], 'old-model');
|
|
});
|
|
|
|
test(
|
|
'model writes reject stale community, foreign agent and unsupported effort',
|
|
() async {
|
|
final (controller, registry) = await editableAgent();
|
|
expect(await saveModel(controller, current: false), isFalse);
|
|
expect(await saveModel(controller, key: 'a' * 64), isFalse);
|
|
expect(await saveModel(controller, effort: 'invalid'), isFalse);
|
|
expect(registry.calls, isNot(contains('update-agent')));
|
|
},
|
|
);
|
|
|
|
test(
|
|
'model writes require current account binding and community owner',
|
|
() async {
|
|
final (controller, registry) = await editableAgent();
|
|
registry.bound = 'a' * 64;
|
|
expect(await saveModel(controller), isFalse);
|
|
expect(registry.calls, isNot(contains('update-agent')));
|
|
},
|
|
);
|
|
|
|
test('model catalog rejects malformed reasoning choices', () async {
|
|
final (controller, registry) = await editableAgent();
|
|
registry.models = [
|
|
{'id': 'bad', 'display_name': 'Bad', 'supported_reasoning_efforts': []},
|
|
];
|
|
expect(controller.modelCatalog(), throwsStateError);
|
|
});
|
|
|
|
test(
|
|
'reconnect adopts only the server-bound saved community identity',
|
|
() async {
|
|
final raw = jsonEncode([
|
|
{'name': 'Pilot', 'nsec': fakeOwner.nsec, 'pubkey': fakeOwner.public},
|
|
{'name': 'Other', 'nsec': fakeAgentKeys.nsec, 'pubkey': fakeAgent},
|
|
]);
|
|
await communitySecureStorage.write(key: 'buzz_communities', value: raw);
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = RelayFakeController(registry);
|
|
addTearDown(() {
|
|
controller.dispose();
|
|
controller.relayAuth.dispose();
|
|
registry.dispose();
|
|
});
|
|
expect(await controller.run(controller.refresh), isTrue);
|
|
expect(controller.owner, fakeOwner.public);
|
|
expect(
|
|
(await AccountIdentityStore().forSubject(fakeSubject).read())?.pubkey,
|
|
fakeOwner.public,
|
|
);
|
|
expect(await communitySecureStorage.read(key: 'buzz_communities'), raw);
|
|
expect(registry.calls, ['list-agents']);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'reconnect never adopts an unrelated device key or rebinds the account',
|
|
() async {
|
|
await communitySecureStorage.write(
|
|
key: 'buzz_communities',
|
|
value: jsonEncode([
|
|
{'name': 'Other', 'nsec': fakeAgentKeys.nsec, 'pubkey': fakeAgent},
|
|
]),
|
|
);
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = RelayFakeController(registry);
|
|
addTearDown(() {
|
|
controller.dispose();
|
|
controller.relayAuth.dispose();
|
|
registry.dispose();
|
|
});
|
|
expect(await controller.run(controller.refresh), isFalse);
|
|
expect(
|
|
await AccountIdentityStore().forSubject(fakeSubject).read(),
|
|
isNull,
|
|
);
|
|
expect(registry.calls, isEmpty);
|
|
expect(controller.failure, contains('absent'));
|
|
},
|
|
);
|
|
|
|
test('reconnect adopts a matching retained paired identity', () async {
|
|
await AccountIdentityStore()
|
|
.forPairing(fakeOwner.public)
|
|
.create(fakeOwner.nsec);
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = RelayFakeController(registry);
|
|
addTearDown(() {
|
|
controller.dispose();
|
|
controller.relayAuth.dispose();
|
|
registry.dispose();
|
|
});
|
|
expect(await controller.run(controller.refresh), isTrue);
|
|
expect(
|
|
(await AccountIdentityStore().forSubject(fakeSubject).read())?.pubkey,
|
|
fakeOwner.public,
|
|
);
|
|
expect(
|
|
(await AccountIdentityStore().forPairing(fakeOwner.public).read())
|
|
?.pubkey,
|
|
fakeOwner.public,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'creation normalizes saved HTTPS community URLs for attestation',
|
|
() async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = await _controller(registry);
|
|
final community = Community.create(
|
|
name: 'Pilot',
|
|
relayUrl: 'https://test.${HostedEnvironment.communitySuffix}',
|
|
pubkey: fakeOwner.public,
|
|
nsec: fakeOwner.nsec,
|
|
);
|
|
expect(
|
|
await controller.create(
|
|
community,
|
|
'Tester',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/attest-agent']?['community_url'],
|
|
'wss://test.${HostedEnvironment.communitySuffix}',
|
|
);
|
|
expect(
|
|
registry.calls.where((call) => call == 'register-agent'),
|
|
hasLength(1),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'Bestie save failure resumes the same completed agent identity',
|
|
() async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
await AccountIdentityStore()
|
|
.forSubject(fakeSubject)
|
|
.create(fakeOwner.nsec);
|
|
var fail = true;
|
|
var completions = 0;
|
|
final controller = RelayFakeController(
|
|
registry,
|
|
onCreated: (community, pubkey) async {
|
|
expect(pubkey, fakeAgent);
|
|
expect(registry.calls, contains('publish-policy'));
|
|
completions++;
|
|
if (fail) throw StateError('Bestie save unavailable');
|
|
},
|
|
);
|
|
addTearDown(() {
|
|
controller.dispose();
|
|
controller.relayAuth.dispose();
|
|
registry.dispose();
|
|
});
|
|
Future<bool> create() => controller.create(
|
|
_community(),
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
);
|
|
expect(await create(), isFalse);
|
|
expect(controller.pending?['pubkey'], fakeAgent);
|
|
fail = false;
|
|
expect(await create(), isTrue);
|
|
expect(controller.pending, isNull);
|
|
expect(completions, 2);
|
|
expect(
|
|
registry.calls.where((call) => call == 'register-agent'),
|
|
hasLength(1),
|
|
);
|
|
},
|
|
);
|
|
|
|
test('model setup requires a catalog-confirmed selection', () async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = await _controller(registry);
|
|
expect(await controller.create(_community(), 'Scout'), isFalse);
|
|
expect(controller.pending, isNull);
|
|
expect(registry.calls, isEmpty);
|
|
expect(controller.failure, 'Choose a model and reasoning effort.');
|
|
expect(
|
|
await controller.create(
|
|
_community(),
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'max',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(registry.calls, ['get-model-catalog']);
|
|
expect(controller.pending, isNull);
|
|
final catalog = await controller.modelCatalog();
|
|
expect(catalog.first.id, 'test-model');
|
|
expect(catalog.last.efforts, ['max']);
|
|
});
|
|
|
|
test('remote agents follow the build-time Builderlab gate', () {
|
|
expect(supportsRemoteAgents(_community()), HostedEnvironment.enabled);
|
|
expect(supportsRemoteAgents(null), isFalse);
|
|
final selfHosted = Community.create(
|
|
name: 'Self hosted',
|
|
relayUrl: 'wss://relay.example.com',
|
|
pubkey: fakeOwner.public,
|
|
);
|
|
expect(supportsRemoteAgents(selfHosted), isFalse);
|
|
});
|
|
|
|
test('every remote agent surface waits for Builderlab sign-in', () async {
|
|
final registry = FakeRegistry(signedIn: false);
|
|
addTearDown(registry.dispose);
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
builderlabAccountProvider.overrideWithValue(registry),
|
|
activeCommunityProvider.overrideWith((ref) async => _community()),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
await container.read(activeCommunityProvider.future);
|
|
expect(container.read(remoteAgentsEnabledProvider), isFalse);
|
|
|
|
registry.signedIn = true;
|
|
expect(
|
|
container.read(remoteAgentsEnabledProvider),
|
|
HostedEnvironment.enabled,
|
|
);
|
|
// A community refresh keeps the surfaces open on the previous value.
|
|
container.invalidate(activeCommunityProvider, asReload: true);
|
|
expect(container.read(activeCommunityProvider).isLoading, isTrue);
|
|
expect(
|
|
container.read(remoteAgentsEnabledProvider),
|
|
HostedEnvironment.enabled,
|
|
);
|
|
registry.signedIn = false;
|
|
expect(container.read(remoteAgentsEnabledProvider), isFalse);
|
|
});
|
|
|
|
test('a saved Builderlab session is restored on cold start', () async {
|
|
final registry = _RestoringRegistry();
|
|
addTearDown(registry.dispose);
|
|
final container = ProviderContainer(
|
|
overrides: [builderlabAccountProvider.overrideWithValue(registry)],
|
|
);
|
|
addTearDown(container.dispose);
|
|
expect(container.read(builderlabSignedInProvider), isFalse);
|
|
// Nothing else restores the account; the gate itself must.
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(
|
|
container.read(builderlabSignedInProvider),
|
|
HostedEnvironment.enabled,
|
|
);
|
|
});
|
|
|
|
test('a failed listing is reported instead of an empty registry', () async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public)..failListing = true;
|
|
final controller = await _controller(registry);
|
|
expect(await controller.run(controller.refresh), isFalse);
|
|
expect(controller.listingFailed, isTrue);
|
|
registry.failListing = false;
|
|
expect(await controller.run(controller.refresh), isTrue);
|
|
expect(controller.listingFailed, isFalse);
|
|
});
|
|
|
|
test('listing requires a signed-in account bound to the saved key', () async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public, signedIn: false);
|
|
final controller = await _controller(registry);
|
|
registry.listed = [
|
|
{
|
|
'agent_pubkey': fakeAgent,
|
|
'agent_name': 'Scout',
|
|
'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE',
|
|
},
|
|
];
|
|
|
|
expect(await controller.run(controller.refresh), isTrue);
|
|
expect(registry.calls, isEmpty);
|
|
expect(controller.activeAgents, isEmpty);
|
|
expect(controller.failure, isEmpty);
|
|
|
|
registry
|
|
..signedIn = true
|
|
..bound = nostr.Keys('0' * 63 + '3').public;
|
|
expect(await controller.run(controller.refresh), isFalse);
|
|
expect(registry.calls, isEmpty);
|
|
expect(controller.failure, 'Buzz identity: mismatch.');
|
|
|
|
registry.bound = fakeOwner.public;
|
|
expect(await controller.run(controller.refresh), isTrue);
|
|
expect(registry.calls, ['list-agents']);
|
|
expect(controller.owner, fakeOwner.public);
|
|
expect(controller.activeAgents, {fakeAgent});
|
|
|
|
registry.signedIn = false;
|
|
expect(controller.activeAgents, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'failed setup resumes the same identity; instructions stay private',
|
|
() async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public)
|
|
..failInstructions = true;
|
|
final controller = await _controller(registry);
|
|
final community = _community();
|
|
|
|
expect(
|
|
await controller.create(
|
|
community,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
instructions: 'x' * 20001,
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
await controller.create(
|
|
_community(pubkey: 'f' * 64),
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(registry.calls, isEmpty);
|
|
expect(controller.pending, isNull);
|
|
|
|
expect(
|
|
await controller.create(
|
|
community,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
instructions: 'Reply concisely.',
|
|
pictureUrl: 'https://example.com/avatar.png',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(registry.calls, [
|
|
'get-model-catalog',
|
|
'register-agent',
|
|
'attest-agent',
|
|
'update-agent',
|
|
]);
|
|
expect(controller.pending?['pubkey'], fakeAgent);
|
|
expect(controller.pending?['picture'], 'https://example.com/avatar.png');
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/register-agent']?['agent_picture_url'],
|
|
'https://example.com/avatar.png',
|
|
);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
expect(prefs.getString('$_scope:pending'), isNotNull);
|
|
expect(
|
|
prefs.getString('$_scope:pending'),
|
|
isNot(contains('Reply concisely.')),
|
|
);
|
|
|
|
// A fresh controller restores the pending setup from storage.
|
|
final resumed = RelayFakeController(registry);
|
|
addTearDown(() {
|
|
resumed.dispose();
|
|
resumed.relayAuth.dispose();
|
|
});
|
|
registry
|
|
..failInstructions = false
|
|
..calls.clear();
|
|
expect(
|
|
await resumed.create(community, 'Other', instructions: 'Other'),
|
|
isTrue,
|
|
);
|
|
expect(registry.calls, [
|
|
'get-model-catalog',
|
|
'attest-agent',
|
|
'update-agent',
|
|
'publish-policy',
|
|
'list-agents',
|
|
]);
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/update-agent']?['agent_instructions'],
|
|
'Reply concisely.',
|
|
);
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/attest-agent']?['community_url'],
|
|
community.relayUrl,
|
|
);
|
|
expect(registry.calls, isNot(contains('add-buzz-subscription')));
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/update-agent']?['model_id'],
|
|
'test-model',
|
|
);
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/update-agent']?['reasoning_effort'],
|
|
'high',
|
|
);
|
|
expect(resumed.pending, isNull);
|
|
expect(prefs.getString('$_scope:pending'), isNull);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'deletion requires confirmed registration absence; disabled stays retryable',
|
|
() async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = await _controller(registry);
|
|
registry.listed = [
|
|
{
|
|
'agent_pubkey': fakeAgent,
|
|
'agent_id': 'remote-id',
|
|
'agent_instructions': 'Private prompt.',
|
|
},
|
|
];
|
|
await controller.run(controller.refresh);
|
|
registry.calls.clear();
|
|
var registrationExists = true;
|
|
var current = true;
|
|
Future<bool> remove() => controller.deleteAgent(
|
|
pubkey: fakeAgent,
|
|
community: _community(),
|
|
isCurrent: () => current,
|
|
fetch: (filter) async {
|
|
registry.calls.add('read-registration');
|
|
expect(filter.kinds, [30177]);
|
|
expect(filter.authors, [fakeOwner.public]);
|
|
expect(filter.tags, {
|
|
'#d': [fakeAgent],
|
|
});
|
|
return registrationExists
|
|
? [
|
|
NostrEvent(
|
|
id: 'registration',
|
|
pubkey: fakeOwner.public,
|
|
createdAt: 1,
|
|
kind: 30177,
|
|
tags: [
|
|
['d', fakeAgent],
|
|
],
|
|
content: '{}',
|
|
sig: '',
|
|
),
|
|
]
|
|
: [];
|
|
},
|
|
);
|
|
|
|
controller.rejectRemoval = true;
|
|
expect(await remove(), isFalse);
|
|
expect(registry.calls, ['remove-registration']);
|
|
// The retry journal is plain preferences, so it never holds the prompt.
|
|
final journal = (await SharedPreferences.getInstance()).getString(
|
|
'$_scope:delete-retries',
|
|
);
|
|
expect(journal, contains('remote-id'));
|
|
expect(journal, isNot(contains('Private prompt.')));
|
|
registry.calls.clear();
|
|
controller.rejectRemoval = false;
|
|
expect(await remove(), isFalse);
|
|
expect(registry.calls, ['remove-registration', 'read-registration']);
|
|
registry
|
|
..calls.clear()
|
|
..deleteStatus = 2;
|
|
registrationExists = false;
|
|
expect(await remove(), isFalse);
|
|
expect(controller.failure, contains('disabled'));
|
|
expect(controller.agents, hasLength(1));
|
|
|
|
// The relay registration is gone, but the listing keeps a retry row.
|
|
registry.listed = [];
|
|
await controller.run(controller.refresh);
|
|
expect(controller.agents.single['agent_id'], 'remote-id');
|
|
|
|
registry.calls.clear();
|
|
current = false;
|
|
expect(await remove(), isFalse);
|
|
expect(registry.calls, isEmpty);
|
|
current = true;
|
|
registry.deleteStatus = 'DELETE_AGENT_STATUS_NOT_FOUND';
|
|
expect(await remove(), isTrue);
|
|
expect(registry.calls.last, 'delete-agent');
|
|
expect(controller.agents, isEmpty);
|
|
expect(controller.deletedAgents, {fakeAgent});
|
|
await controller.run(controller.refresh);
|
|
expect(controller.agents, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('a setup whose instructions were lost asks for them again', () async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public)
|
|
..failInstructions = true;
|
|
final controller = await _controller(registry);
|
|
final community = _community();
|
|
expect(
|
|
await controller.create(
|
|
community,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
instructions: 'Private.',
|
|
),
|
|
isFalse,
|
|
);
|
|
final request = controller.pending!['request'];
|
|
// Device-only secure storage is gone; preferences survived (a restore).
|
|
await accountSecureStorage.delete(key: '$_scope:instructions:$request');
|
|
|
|
final restarted = RelayFakeController(registry);
|
|
addTearDown(() {
|
|
restarted.dispose();
|
|
restarted.relayAuth.dispose();
|
|
});
|
|
expect(await restarted.run(restarted.refresh), isTrue);
|
|
expect(restarted.pending?['instructionsMissing'], isTrue);
|
|
|
|
registry
|
|
..failInstructions = false
|
|
..calls.clear();
|
|
expect(
|
|
await restarted.create(
|
|
community,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
instructions: 'Again.',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/update-agent']?['agent_instructions'],
|
|
'Again.',
|
|
);
|
|
expect(restarted.pending, isNull);
|
|
final prefs = await SharedPreferences.getInstance();
|
|
expect(prefs.getString('$_scope:pending'), isNull);
|
|
});
|
|
|
|
test(
|
|
'registrations use a WebSocket URL for https-saved communities',
|
|
() async {
|
|
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
addTearDown(() => server.close(force: true));
|
|
final kinds = <int>[];
|
|
final registrations = <Map<String, dynamic>>[];
|
|
final published = <NostrEvent>[];
|
|
server.listen((request) async {
|
|
final peer = await WebSocketTransformer.upgrade(request);
|
|
addTearDown(peer.close);
|
|
peer.listen((frame) {
|
|
final data = jsonDecode(frame as String) as List;
|
|
final event = Map<String, dynamic>.from(data[1] as Map);
|
|
if (data.first == 'EVENT') {
|
|
kinds.add(event['kind'] as int);
|
|
if (event['kind'] == 30177) {
|
|
registrations.add(
|
|
jsonDecode(event['content'] as String) as Map<String, dynamic>,
|
|
);
|
|
}
|
|
published.add(NostrEvent.fromJson(event));
|
|
}
|
|
peer.add(jsonEncode(['OK', event['id'], true, '']));
|
|
});
|
|
peer.add(jsonEncode(['AUTH', 'challenge']));
|
|
});
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
await AccountIdentityStore()
|
|
.forSubject(fakeSubject)
|
|
.create(fakeOwner.nsec);
|
|
final controller = RemoteAgentController(
|
|
account: registry,
|
|
keys: AccountIdentityStore(),
|
|
relayAuth: HostedRelayAuth(registry),
|
|
);
|
|
addTearDown(() {
|
|
controller.dispose();
|
|
controller.relayAuth.dispose();
|
|
});
|
|
// Stored the way paired communities may keep it: an HTTP(S) origin.
|
|
final relay = 'http://127.0.0.1:${server.port}';
|
|
await controller.publishPolicy(relay, {
|
|
'pubkey': fakeAgent,
|
|
'name': 'Scout',
|
|
'picture': 'https://example.com/avatar.png',
|
|
});
|
|
expect(registrations.single['picture'], 'https://example.com/avatar.png');
|
|
await controller.removePolicy(relay, fakeAgent);
|
|
expect(kinds, [30177, 5]);
|
|
},
|
|
);
|
|
|
|
test('a setup without a server identity can be discarded', () async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public)
|
|
..rejectRegistration = true;
|
|
final controller = await _controller(registry);
|
|
final community = _community();
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
expect(
|
|
await controller.create(
|
|
community,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
instructions: 'Private.',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(controller.failure, contains('NAME_TAKEN'));
|
|
expect(controller.canDiscardPending, isTrue);
|
|
// An HTTP rejection is just as definite.
|
|
registry
|
|
..rejectRegistration = false
|
|
..refuseRegistration = true;
|
|
expect(
|
|
await controller.create(
|
|
community,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(controller.failure, contains('rejected'));
|
|
expect(controller.canDiscardPending, isTrue);
|
|
final request = controller.pending!['request'];
|
|
expect(prefs.getString('$_scope:pending'), isNotNull);
|
|
|
|
expect(await controller.discardPending(), isTrue);
|
|
expect(controller.pending, isNull);
|
|
expect(prefs.getString('$_scope:pending'), isNull);
|
|
expect(
|
|
await accountSecureStorage.read(key: '$_scope:instructions:$request'),
|
|
isNull,
|
|
);
|
|
|
|
// A lost response may hide a committed identity, so it can only resume.
|
|
registry
|
|
..refuseRegistration = false
|
|
..loseRegistration = true;
|
|
expect(
|
|
await controller.create(
|
|
community,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
instructions: 'Private.',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(controller.pending?['pubkey'], isNull);
|
|
expect(controller.canDiscardPending, isFalse);
|
|
expect(await controller.discardPending(), isFalse);
|
|
final lost = controller.pending!['request'];
|
|
expect(jsonDecode(prefs.getString('$_scope:pending')!)['request'], lost);
|
|
|
|
// Until the identity is known, any listed agent could be the lost one.
|
|
final other = _keys('3').public;
|
|
registry.listed = [
|
|
...registry.listed,
|
|
{'agent_pubkey': other, 'agent_id': 'remote-id'},
|
|
];
|
|
await controller.run(controller.refresh);
|
|
final elsewhere = Community.create(
|
|
name: 'Other',
|
|
relayUrl: 'wss://other.${HostedEnvironment.communitySuffix}',
|
|
pubkey: fakeOwner.public,
|
|
nsec: fakeOwner.nsec,
|
|
);
|
|
Future<bool> remove(String pubkey) => controller.deleteAgent(
|
|
pubkey: pubkey,
|
|
community: elsewhere,
|
|
isCurrent: () => true,
|
|
fetch: (_) async => [],
|
|
);
|
|
expect(await remove(other), isFalse);
|
|
expect(controller.failure, contains('Resume the unfinished setup'));
|
|
|
|
// Resume from another community reuses the idempotency key and recovers
|
|
// the same identity; finishing it still needs the original community.
|
|
registry
|
|
..loseRegistration = false
|
|
..bodies.clear();
|
|
expect(
|
|
await controller.create(
|
|
elsewhere,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(controller.failure, contains('Agent identity recovered'));
|
|
expect(
|
|
registry.bodies['/v3/beekeeper/register-agent']?['idempotency_key'],
|
|
lost,
|
|
);
|
|
registry.bodies.clear();
|
|
expect(
|
|
await controller.create(
|
|
elsewhere,
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(controller.failure, contains('original community'));
|
|
expect(registry.bodies, isEmpty);
|
|
expect(controller.pending?['pubkey'], fakeAgent);
|
|
expect(await controller.discardPending(), isFalse);
|
|
expect(controller.pending?['pubkey'], fakeAgent);
|
|
|
|
// Once known, the unfinished setup blocks deleting only its own agent.
|
|
expect(await remove(fakeAgent), isFalse);
|
|
expect(controller.failure, contains('Resume the unfinished setup'));
|
|
expect(await remove(other), isTrue);
|
|
expect(controller.deletedAgents, {other});
|
|
expect(controller.pending?['pubkey'], fakeAgent);
|
|
expect(prefs.getString('$_scope:pending'), isNotNull);
|
|
});
|
|
|
|
test('another account never sees the previous registry', () async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = await _controller(registry);
|
|
registry.listed = [
|
|
{
|
|
'agent_pubkey': fakeAgent,
|
|
'agent_name': 'Scout',
|
|
'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE',
|
|
},
|
|
];
|
|
await controller.run(controller.refresh);
|
|
expect(controller.activeAgents, {fakeAgent});
|
|
|
|
var notified = 0;
|
|
controller.addListener(() => notified++);
|
|
registry.subject = 'other-owner';
|
|
expect(notified, 1);
|
|
expect(controller.activeAgents, isEmpty);
|
|
// The other account has no saved key, so its registry is never listed.
|
|
expect(await controller.run(controller.refresh), isFalse);
|
|
expect(controller.agents, isEmpty);
|
|
expect(controller.owner, isNull);
|
|
});
|
|
|
|
test('a failed registry load retries on the next load', () async {
|
|
final registry = FakeRegistry(bound: 'f' * 64);
|
|
final controller = await _controller(registry);
|
|
final relay = _community().relayUrl;
|
|
await controller.load(relay);
|
|
expect(controller.failure, 'Buzz identity: mismatch.');
|
|
registry.bound = fakeOwner.public;
|
|
await controller.load(relay);
|
|
expect(registry.calls, ['list-agents']);
|
|
await controller.load(relay);
|
|
expect(registry.calls, ['list-agents']);
|
|
});
|
|
|
|
test(
|
|
'availability requires an owned profile and a live registration',
|
|
() async {
|
|
String key(String last) => _keys(last).public;
|
|
final other = key('3');
|
|
final elsewhere = key('5');
|
|
final forged = signedAgentProfile(_keys('8'), fakeOwner).toJson()
|
|
..['sig'] = '0' * 128;
|
|
final profiles = [
|
|
signedAgentProfile(fakeAgentKeys, fakeOwner),
|
|
// Attested by someone else, so not this owner's agent.
|
|
signedAgentProfile(_keys('3'), _keys('4')),
|
|
// Signed conditions that exclude this profile, and a forged envelope.
|
|
signedAgentProfile(_keys('6'), fakeOwner, conditions: 'kind=1'),
|
|
signedAgentProfile(_keys('7'), fakeOwner, conditions: 'created_at<50'),
|
|
NostrEvent.fromJson(forged),
|
|
];
|
|
var registrations = <NostrEvent>[];
|
|
Future<List<NostrEvent>> fetch(NostrFilter filter) async {
|
|
if (filter.kinds.single == 0) return profiles;
|
|
expect(filter.kinds, [30177]);
|
|
expect(filter.authors, [fakeOwner.public]);
|
|
expect(filter.tags, {
|
|
'#d': [fakeAgent],
|
|
});
|
|
return registrations;
|
|
}
|
|
|
|
final keys = {fakeAgent, other, elsewhere, key('6'), key('7'), key('8')};
|
|
// The list keeps owned agents, so an interrupted delete stays retryable.
|
|
expect(await ownedCommunityAgents(keys, fakeOwner.public, fetch), {
|
|
fakeAgent,
|
|
});
|
|
expect(await liveCommunityAgents(keys, fakeOwner.public, fetch), isEmpty);
|
|
registrations = [_registration(fakeAgent)];
|
|
expect(await liveCommunityAgents(keys, fakeOwner.public, fetch), {
|
|
fakeAgent,
|
|
});
|
|
expect(await liveCommunityAgents(keys, null, fetch), isEmpty);
|
|
},
|
|
);
|
|
|
|
test('presence stays local until the agent registration is live', () async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public)
|
|
..listed = [
|
|
{
|
|
'agent_pubkey': fakeAgent,
|
|
'agent_id': 'remote-id',
|
|
'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE',
|
|
},
|
|
];
|
|
final controller = await _controller(registry);
|
|
final relay = _AgentRelay();
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
ownedAgentDirectoryProvider.overrideWith((ref) async => []),
|
|
myPubkeyProvider.overrideWithValue(fakeOwner.public),
|
|
presenceCacheProvider.overrideWith(_AgentPresence.new),
|
|
activeCommunityProvider.overrideWith((ref) async => _community()),
|
|
remoteAgentControllerProvider.overrideWithValue(controller),
|
|
relaySessionProvider.overrideWith(() => relay),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
await container.read(activeCommunityProvider.future);
|
|
expect(controller.registryLoaded, isFalse);
|
|
await controller.load(_community().relayUrl);
|
|
expect(controller.registryLoaded, isTrue);
|
|
|
|
expect(await container.read(availableRemoteAgentsProvider.future), isEmpty);
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline');
|
|
|
|
relay.live = true;
|
|
container.invalidate(availableRemoteAgentsProvider);
|
|
expect(await container.read(availableRemoteAgentsProvider.future), {
|
|
fakeAgent,
|
|
});
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
|
|
|
|
// Removing the registration hides it even when the runtime stays ACTIVE.
|
|
registry.deleteStatus = 2;
|
|
relay.live = false;
|
|
expect(
|
|
await controller.deleteAgent(
|
|
pubkey: fakeAgent,
|
|
community: _community(),
|
|
isCurrent: () => true,
|
|
fetch: (_) async => [],
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(controller.activeAgents, {fakeAgent});
|
|
expect(await container.read(availableRemoteAgentsProvider.future), isEmpty);
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline');
|
|
|
|
// Publishing a registration for the still-ACTIVE agent shows it again.
|
|
relay.live = true;
|
|
expect(
|
|
await controller.create(
|
|
_community(),
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(controller.activeAgents, {fakeAgent});
|
|
expect(await container.read(availableRemoteAgentsProvider.future), {
|
|
fakeAgent,
|
|
});
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
|
|
}, skip: !HostedEnvironment.enabled);
|
|
|
|
test(
|
|
'Solo agent choices reload after this device creates an agent',
|
|
() async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = await _controller(registry);
|
|
final relay = _InventoryRelay();
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
relaySessionProvider.overrideWith(() => relay),
|
|
meSyncProvider.overrideWithValue(
|
|
MeSync(
|
|
relay,
|
|
RelayConfig(baseUrl: 'https://relay.test', nsec: fakeOwner.nsec),
|
|
),
|
|
),
|
|
identityArchivesProvider.overrideWith((ref) async => {}),
|
|
remoteAgentControllerProvider.overrideWithValue(controller),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
final choices = container.listen(meAssistantsProvider, (_, _) {});
|
|
addTearDown(choices.close);
|
|
expect(await container.read(meAssistantsProvider.future), isEmpty);
|
|
|
|
relay.listed = true;
|
|
expect(
|
|
await controller.create(
|
|
_community(),
|
|
'Scout',
|
|
modelId: 'test-model',
|
|
reasoningEffort: 'high',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
(await container.read(
|
|
meAssistantsProvider.future,
|
|
)).map((a) => a.pubkey),
|
|
[fakeAgent],
|
|
);
|
|
},
|
|
skip: !HostedEnvironment.enabled,
|
|
);
|
|
|
|
test(
|
|
'runtime absence and deletion survive sign-out and a fresh owner-scoped load',
|
|
() async {
|
|
final registry = FakeRegistry(bound: fakeOwner.public);
|
|
final controller = await _controller(registry);
|
|
ProviderContainer open(String owner) => ProviderContainer(
|
|
overrides: [
|
|
myPubkeyProvider.overrideWithValue(owner),
|
|
ownedAgentDirectoryProvider.overrideWith(
|
|
(ref) async => [
|
|
OwnedAgentDirectoryEntry(
|
|
pubkey: fakeAgent,
|
|
runtime: 'block.builderlab/builderlab',
|
|
),
|
|
],
|
|
),
|
|
presenceCacheProvider.overrideWith(_AgentPresence.new),
|
|
remoteAgentControllerProvider.overrideWithValue(controller),
|
|
availableRemoteAgentsProvider.overrideWith((ref) async => {}),
|
|
],
|
|
);
|
|
final container = open(fakeOwner.public);
|
|
addTearDown(container.dispose);
|
|
await container.read(ownedAgentDirectoryProvider.future);
|
|
await container.read(availableRemoteAgentsProvider.future);
|
|
await container.read(savedRemoteAvailabilityProvider.future);
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'online');
|
|
await controller.run(controller.refresh);
|
|
await container.pump();
|
|
expect(controller.registryLoaded, isTrue);
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline');
|
|
registry.signedIn = false;
|
|
expect(controller.registryLoaded, isFalse);
|
|
await container.read(savedRemoteAvailabilityProvider.future);
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline');
|
|
final restarted = open(fakeOwner.public);
|
|
addTearDown(restarted.dispose);
|
|
await restarted.read(ownedAgentDirectoryProvider.future);
|
|
await restarted.read(savedRemoteAvailabilityProvider.future);
|
|
expect(restarted.read(presentedPresenceProvider)[fakeAgent], 'offline');
|
|
final otherOwner = open(_keys('3').public);
|
|
addTearDown(otherOwner.dispose);
|
|
await otherOwner.read(ownedAgentDirectoryProvider.future);
|
|
await otherOwner.read(savedRemoteAvailabilityProvider.future);
|
|
expect(otherOwner.read(presentedPresenceProvider)[fakeAgent], 'online');
|
|
|
|
// A confirmed deletion must also survive an old ACTIVE registry row.
|
|
registry.signedIn = true;
|
|
registry.listed = [
|
|
{
|
|
'agent_pubkey': fakeAgent,
|
|
'agent_id': 'remote-id',
|
|
'status': 'BEEKEEPER_AGENT_STATUS_ACTIVE',
|
|
},
|
|
];
|
|
await controller.run(controller.refresh);
|
|
expect(
|
|
await controller.deleteAgent(
|
|
pubkey: fakeAgent,
|
|
community: _community(),
|
|
isCurrent: () => true,
|
|
fetch: (_) async => [],
|
|
),
|
|
isTrue,
|
|
);
|
|
registry.signedIn = false;
|
|
await container.read(savedRemoteAvailabilityProvider.future);
|
|
expect(container.read(presentedPresenceProvider)[fakeAgent], 'offline');
|
|
},
|
|
);
|
|
|
|
test('owner proof verifies for only the provisioned agent key', () {
|
|
final other = nostr.Keys('0' * 63 + '3');
|
|
final auth = signOwnerAuthorization(fakeOwner, fakeAgent);
|
|
expect(verifiedOaOwnerPubkey([auth], fakeAgent), fakeOwner.public);
|
|
expect(verifiedOaOwnerPubkey([auth], other.public), isNull);
|
|
expect(
|
|
() => signOwnerAuthorization(fakeOwner, fakeOwner.public),
|
|
throwsStateError,
|
|
);
|
|
});
|
|
|
|
test('disabled and missing statuses cannot count as activation', () {
|
|
requireStatus({'status': 'ATTEST_AGENT_STATUS_ACTIVE'}, 'ACTIVE', 1);
|
|
requireStatus({'status': 1}, 'ACTIVE', 1);
|
|
for (final result in [
|
|
{'status': 'ATTEST_AGENT_STATUS_FEATURE_DISABLED'},
|
|
<String, dynamic>{},
|
|
]) {
|
|
expect(() => requireStatus(result, 'ACTIVE', 1), throwsStateError);
|
|
}
|
|
});
|
|
|
|
test(
|
|
'remote availability shows Online without changing local presence',
|
|
() async {
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
ownedAgentDirectoryProvider.overrideWith((ref) async => []),
|
|
myPubkeyProvider.overrideWithValue(fakeOwner.public),
|
|
presenceCacheProvider.overrideWith(_Presence.new),
|
|
availableRemoteAgentsProvider.overrideWith((ref) async => {'remote'}),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
await container.read(availableRemoteAgentsProvider.future);
|
|
expect(container.read(presentedPresenceProvider), {
|
|
'remote': 'online',
|
|
'local': 'away',
|
|
});
|
|
expect(container.read(presenceCacheProvider)['remote'], 'offline');
|
|
},
|
|
);
|
|
}
|
|
|
|
class _Presence extends PresenceCacheNotifier {
|
|
@override
|
|
Map<String, String> build() => {'remote': 'offline', 'local': 'away'};
|
|
}
|
|
|
|
class _AgentPresence extends PresenceCacheNotifier {
|
|
@override
|
|
Map<String, String> build() => {fakeAgent: 'offline'};
|
|
}
|
|
|
|
NostrEvent _registration(String agent) => NostrEvent(
|
|
id: 'registration-$agent',
|
|
pubkey: fakeOwner.public,
|
|
createdAt: 1,
|
|
kind: 30177,
|
|
tags: [
|
|
['d', agent],
|
|
],
|
|
content: '{}',
|
|
sig: '',
|
|
);
|
|
|
|
/// Serves the owner's agent profile, and its registration once [live].
|
|
class _AgentRelay extends RelaySessionNotifier {
|
|
bool live = false;
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> fetchHistory(
|
|
NostrFilter filter, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async => switch (filter.kinds.single) {
|
|
0 => [signedAgentProfile(fakeAgentKeys, fakeOwner)],
|
|
30177 when live => [_registration(fakeAgent)],
|
|
_ => [],
|
|
};
|
|
}
|
|
|
|
/// Serves Solo's inventory reads: the owner's signed registration once
|
|
/// [listed], and the agent's attested profile.
|
|
class _InventoryRelay extends RelaySessionNotifier {
|
|
bool listed = false;
|
|
|
|
@override
|
|
SessionState build() => const SessionState(status: SessionStatus.connected);
|
|
|
|
@override
|
|
Future<List<NostrEvent>> queryRelay(
|
|
List<NostrFilter> filters, {
|
|
Duration timeout = const Duration(seconds: 8),
|
|
}) async => switch (filters.single.kinds.single) {
|
|
0 => [signedAgentProfile(fakeAgentKeys, fakeOwner)],
|
|
30177 when listed => [
|
|
NostrEvent.fromJson(
|
|
nostr.Event.from(
|
|
kind: 30177,
|
|
content: jsonEncode({
|
|
'name': 'Scout',
|
|
'parallelism': 1,
|
|
'respond_to': 'owner-only',
|
|
}),
|
|
secretKey: fakeOwner.secret,
|
|
createdAt: 1,
|
|
tags: [
|
|
['d', fakeAgent],
|
|
],
|
|
).toMap(),
|
|
),
|
|
],
|
|
_ => [],
|
|
};
|
|
}
|