diff --git a/lib/builtin/claude/src/claude_config.dart b/lib/builtin/claude/src/claude_config.dart index 36805f25..f65f1ba5 100644 --- a/lib/builtin/claude/src/claude_config.dart +++ b/lib/builtin/claude/src/claude_config.dart @@ -253,6 +253,7 @@ class ClaudeConfig extends ChangeNotifier { _version = _parseVersion(await _guard(_versionRunner)); await _readProbeCache(); await _loadDiskConfig(); + if (_disposed) return; // activation fired-and-forgot; teardown won _startWatchers(); notifyListeners(); } @@ -296,8 +297,14 @@ class ClaudeConfig extends ChangeNotifier { notifyListeners(); } + /// Set when [dispose] runs. The fire-and-forget [load] from extension + /// activation checks this so a teardown racing an in-flight load can't + /// notify (or start watchers on) a disposed notifier. + bool _disposed = false; + @override void dispose() { + _disposed = true; _stopWatching(); super.dispose(); } diff --git a/lib/kernel/src/keymap/keymap_service.dart b/lib/kernel/src/keymap/keymap_service.dart index c8279236..f78232f4 100644 --- a/lib/kernel/src/keymap/keymap_service.dart +++ b/lib/kernel/src/keymap/keymap_service.dart @@ -167,20 +167,37 @@ class KeymapService extends ChangeNotifier { return km.match(sequence, _scope).exact; } + /// Scope-flag producers clear their flags from widget dispose() — which + /// during app teardown runs AFTER KernelServices.dispose() has disposed + /// this notifier. Tolerate that ordering instead of asserting (the same + /// fire-and-forget pattern SettingsStore uses). + bool _disposed = false; + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + void _safeNotify() { + if (_disposed) return; + notifyListeners(); + } + /// Set a named scope flag. Producers should call this when their /// state changes so when-clauses re-evaluate correctly. Notifies /// listeners when the value actually changes. void setScopeFlag(String name, bool value) { if (_scope[name] == value) return; _scope[name] = value; - notifyListeners(); + _safeNotify(); } /// Clear a named scope flag. void clearScopeFlag(String name) { if (!_scope.containsKey(name)) return; _scope.remove(name); - notifyListeners(); + _safeNotify(); } /// Switch presets. Persists the new preset name to settings and diff --git a/test/builtin/claude/extension_commands_test.dart b/test/builtin/claude/extension_commands_test.dart index 4b2aa761..e3fd2091 100644 --- a/test/builtin/claude/extension_commands_test.dart +++ b/test/builtin/claude/extension_commands_test.dart @@ -2,14 +2,23 @@ /// exit-code contract — a failure is an ERROR envelope (non-zero CLI /// exit), never `ok` with an `error` field a script can't detect. /// `clide claude.agent.set-permission-mode bogus` exited 0 before this. +/// Plus the activation lifecycle + command success paths. library; +import 'package:clide/builtin/claude/src/activity_cluster.dart' show kActivityFoldLevelKey; +import 'package:clide/builtin/claude/src/claude_config.dart' show activeClaudeConfig; import 'package:clide/builtin/claude/src/extension.dart'; +import 'package:clide/builtin/claude/src/session_orchestrator.dart' show activeSessionOrchestrator; import 'package:clide/clide.dart'; import 'package:clide/extension/extension.dart'; +import 'package:clide/kernel/kernel.dart'; +import 'package:clide/src/daemon/image_commands.dart' show imageShowChannel; import 'package:flutter_test/flutter_test.dart'; +import '../../helpers/kernel_fixture.dart'; + void main() { + TestWidgetsFlutterBinding.ensureInitialized(); // GlobalKey lookups in handlers final ext = ClaudeExtension(); CommandContribution cmd(String id) => ext.contributions.whereType().firstWhere((c) => c.id == id); @@ -44,4 +53,96 @@ void main() { }); } }); + + group('activated lifecycle + success paths', () { + late KernelFixture f; + + setUp(() async { + f = await KernelFixture.create(); + f.services.extensions.register(ClaudeExtension()); + await f.services.extensions.activate('builtin.claude'); + expect(f.services.extensions.isActivated('builtin.claude'), isTrue, reason: f.services.extensions.failedExtensions.toString()); + }); + + tearDown(() async { + await f.services.extensions.deactivate('builtin.claude'); + await f.dispose(); + }); + + Future run(String command, [List args = const []]) { + final c = f.services.commands.get(command); + expect(c, isNotNull, reason: '$command should be registered after activation'); + return c!.run(args); + } + + test('roster verbs succeed once the orchestrator is wired (no-op on unknown ids)', () async { + for (final verb in ['claude.agent.show', 'claude.agent.hide', 'claude.agent.close', 'claude.agent.mute', 'claude.agent.unmute']) { + final r = await run(verb, ['no-such-session']); + expect(r.ok, isTrue, reason: '$verb is idempotent on unknown ids'); + } + final inject = await run('claude.agent.inject-message', ['no-such-session', 'hello']); + expect(inject.ok, isTrue); + final mode = await run('claude.agent.set-permission-mode', ['no-such-session', 'plan']); + expect(mode.ok, isTrue); + expect(mode.data['mode'], 'plan'); + }); + + test('claude.new-secondary and kill-all-sessions succeed with no live panes', () async { + expect((await run('claude.new-secondary')).ok, isTrue); + final killed = await run('claude.kill-all-sessions'); + expect(killed.ok, isTrue); + expect(killed.data['status'], 'killed'); + }); + + test('claude.activity.fold-level cycles and persists the setting (T-235)', () async { + final r1 = await run('claude.activity.fold-level'); + expect(r1.ok, isTrue); + final first = r1.data['foldLevel'] as String; + expect(f.services.settings.get(kActivityFoldLevelKey), first); + final r2 = await run('claude.activity.fold-level'); + expect(r2.data['foldLevel'], isNot(first), reason: 'the level advances each call'); + }); + + test('claude.team-chat.open and .post succeed', () async { + expect((await run('claude.team-chat.open')).ok, isTrue); + final broadcast = await run('claude.team-chat.post', ['hello', 'team']); + expect(broadcast.ok, isTrue); + final directed = await run('claude.team-chat.post', ['@tyre', 'hello', 'you']); + expect(directed.ok, isTrue); + expect(directed.data['to'], 'tyre'); + }); + + test('claude.session-storage degrades cleanly when files.root is unavailable', () async { + // The fixture IPC has no files.root stub → the handler bails out ok + // without opening the dialog. + final r = await run('claude.session-storage'); + expect(r.ok, isTrue); + }); + + test('an image-show message with no live session is dropped silently (T-249)', () async { + f.services.messages.publish('test', imageShowChannel, {'path': '/tmp/x.png'}); + f.services.messages.publish('test', imageShowChannel, {'path': ''}); + await Future.delayed(Duration.zero); + // Nothing to assert beyond "no throw" — there is no conversation to + // receive the card and the CLI already acked at publish time. + }); + + test('a project switch closes sessions that belong to the old root (T-269)', () async { + f.services.events.emit(const ProjectOpened(path: '/repo-one')); + await Future.delayed(Duration.zero); + f.services.events.emit(const ProjectOpened(path: '/repo-one')); + await Future.delayed(Duration.zero); + f.services.events.emit(const ProjectOpened(path: '/repo-two')); + await Future.delayed(Duration.zero); + // No live sessions in this fixture — the sweep runs over an empty set. + expect(activeSessionOrchestrator!.sessions, isEmpty); + }); + + test('deactivate clears the builtin-owned singletons', () async { + expect(activeSessionOrchestrator, isNotNull); + await f.services.extensions.deactivate('builtin.claude'); + expect(activeSessionOrchestrator, isNull); + expect(activeClaudeConfig, isNull); + }); + }); }