Stabilize ARIS packaging and Ollama Cloud settings

This commit is contained in:
Haitao Pan 2026-03-20 09:13:22 +08:00
parent 7a58db73bf
commit 78c2dd6dc2
13 changed files with 673 additions and 43 deletions

View File

@ -4,7 +4,10 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestParseClaudeJSON(t *testing.T) {
@ -60,3 +63,69 @@ func TestCallOpenAICompatible(t *testing.T) {
t.Fatalf("unexpected output: %s", output)
}
}
func TestHandleChatToolRequiresPrompt(t *testing.T) {
t.Setenv("LLM_API_KEY", "test-key")
t.Setenv("LLM_BASE_URL", "http://127.0.0.1:11434/v1")
_, err := handleChatTool(map[string]any{})
if err == nil || err.Error() != "prompt is required" {
t.Fatalf("expected prompt error, got %v", err)
}
}
func TestParseClaudeJSONReturnsErrorForPlainText(t *testing.T) {
t.Parallel()
_, err := parseClaudeJSON("plain text only\n")
if err == nil {
t.Fatal("expected parse error for plain text output")
}
}
func TestCallOpenAICompatibleReturnsStatusError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad gateway", http.StatusBadGateway)
}))
defer server.Close()
_, err := callOpenAICompatible(
server.URL,
"test-key",
"qwen2.5-coder:latest",
[]map[string]string{{"role": "user", "content": "hello"}},
)
if err == nil || err.Error() == "" {
t.Fatal("expected non-2xx status error")
}
}
func TestRunClaudeReviewSurfacesCliExitFailure(t *testing.T) {
tempDir := t.TempDir()
cliPath := filepath.Join(tempDir, "claude")
if err := os.WriteFile(cliPath, []byte("#!/bin/sh\necho boom >&2\nexit 2\n"), 0o755); err != nil {
t.Fatalf("write fake claude script: %v", err)
}
t.Setenv("CLAUDE_BIN", cliPath)
_, err := runClaudeReview("review this", "", "", "", 2*time.Second)
if err == nil || err.Error() == "" {
t.Fatal("expected cli failure")
}
}
func TestRunClaudeReviewSurfacesNonJSONStdout(t *testing.T) {
tempDir := t.TempDir()
cliPath := filepath.Join(tempDir, "claude")
if err := os.WriteFile(cliPath, []byte("#!/bin/sh\necho plain-text-output\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake claude script: %v", err)
}
t.Setenv("CLAUDE_BIN", cliPath)
_, err := runClaudeReview("review this", "", "", "", 2*time.Second)
if err == nil || err.Error() == "" {
t.Fatal("expected non-json stdout error")
}
}

View File

@ -9,10 +9,37 @@ Finder _textEither(String zh, String en) {
);
}
Future<void> _ensureSettingsFocused(WidgetTester tester) async {
final activeSettings = find.byKey(
const ValueKey<String>('assistant-focus-active-title-settings'),
);
if (activeSettings.evaluate().isNotEmpty) {
return;
}
final addSettingsChip = find.byKey(
const ValueKey<String>('assistant-focus-add-settings'),
);
if (addSettingsChip.evaluate().isNotEmpty) {
await tester.tap(addSettingsChip);
await settleIntegrationUi(tester);
return;
}
final addMenu = find.byKey(const Key('assistant-focus-add-menu'));
expect(addMenu, findsOneWidget);
await tester.tap(addMenu);
await settleIntegrationUi(tester);
final settingsItem = _textEither('设置', 'Settings');
expect(settingsItem, findsWidgets);
await tester.tap(settingsItem.last);
await settleIntegrationUi(tester);
}
void main() {
initializeIntegrationHarness();
setUp(resetIntegrationPreferences);
setUp(() async {
await resetIntegrationPreferences();
});
testWidgets('desktop shell opens focused navigation surface', (
WidgetTester tester,
@ -28,7 +55,11 @@ void main() {
find.byKey(const Key('assistant-focus-panel-title')),
findsOneWidget,
);
expect(_textEither('设置', 'Settings'), findsWidgets);
await _ensureSettingsFocused(tester);
expect(
find.byKey(const ValueKey<String>('assistant-focus-active-title-settings')),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await settleIntegrationUi(tester);

View File

@ -9,28 +9,59 @@ Finder _textEither(String zh, String en) {
);
}
Future<void> _ensureSettingsFocused(WidgetTester tester) async {
final activeSettings = find.byKey(
const ValueKey<String>('assistant-focus-active-title-settings'),
);
if (activeSettings.evaluate().isNotEmpty) {
return;
}
final addSettingsChip = find.byKey(
const ValueKey<String>('assistant-focus-add-settings'),
);
if (addSettingsChip.evaluate().isNotEmpty) {
await tester.tap(addSettingsChip);
await settleIntegrationUi(tester);
return;
}
final addMenu = find.byKey(const Key('assistant-focus-add-menu'));
expect(addMenu, findsOneWidget);
await tester.tap(addMenu);
await settleIntegrationUi(tester);
final settingsItem = _textEither('设置', 'Settings');
expect(settingsItem, findsWidgets);
await tester.tap(settingsItem.last);
await settleIntegrationUi(tester);
}
void main() {
initializeIntegrationHarness();
setUp(resetIntegrationPreferences);
setUp(() async {
await resetIntegrationPreferences();
});
testWidgets(
'desktop shell exposes settings entry for gateway configuration',
(WidgetTester tester) async {
await pumpDesktopApp(tester);
await tester.tap(
find.byKey(const Key('assistant-side-pane-tab-navigation')),
);
await settleIntegrationUi(tester);
expect(
find.byKey(const Key('assistant-focus-panel-title')),
findsOneWidget,
);
expect(_textEither('设置', 'Settings'), findsWidgets);
await tester.tap(
find.byKey(const Key('assistant-side-pane-tab-navigation')),
);
await settleIntegrationUi(tester);
expect(
find.byKey(const Key('assistant-focus-panel-title')),
findsOneWidget,
);
await _ensureSettingsFocused(tester);
expect(
find.byKey(const ValueKey<String>('assistant-focus-active-title-settings')),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox.shrink());
await settleIntegrationUi(tester);
},
await tester.pumpWidget(const SizedBox.shrink());
await settleIntegrationUi(tester);
},
);
}

View File

@ -1,6 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app.dart';
@ -8,8 +11,17 @@ void initializeIntegrationHarness() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
}
void resetIntegrationPreferences() {
Future<void> resetIntegrationPreferences() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
try {
final supportDirectory = await getApplicationSupportDirectory();
final xworkmateDirectory = Directory('${supportDirectory.path}/xworkmate');
if (await xworkmateDirectory.exists()) {
await xworkmateDirectory.delete(recursive: true);
}
} catch (_) {
// Keep integration setup best-effort on runners without path support.
}
}
Future<void> pumpDesktopApp(

View File

@ -1182,7 +1182,7 @@ class AppController extends ChangeNotifier {
}) async {
final current = settings;
final sanitized = _sanitizeMultiAgentSettings(
_sanitizeCodeAgentSettings(snapshot),
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
);
setActiveAppLanguage(sanitized.appLanguage);
await _settingsController.saveSnapshot(sanitized);
@ -1438,7 +1438,9 @@ class AppController extends ChangeNotifier {
}
}
final normalized = _sanitizeMultiAgentSettings(
_sanitizeCodeAgentSettings(_settingsController.snapshot),
_sanitizeOllamaCloudSettings(
_sanitizeCodeAgentSettings(_settingsController.snapshot),
),
);
if (normalized.toJsonString() !=
_settingsController.snapshot.toJsonString()) {
@ -1579,6 +1581,19 @@ class AppController extends ChangeNotifier {
return snapshot.copyWith(multiAgent: resolved);
}
SettingsSnapshot _sanitizeOllamaCloudSettings(SettingsSnapshot snapshot) {
final rawBaseUrl = snapshot.ollamaCloud.baseUrl.trim();
final normalized = rawBaseUrl.endsWith('/')
? rawBaseUrl.substring(0, rawBaseUrl.length - 1)
: rawBaseUrl;
if (normalized != 'https://ollama.svc.plus') {
return snapshot;
}
return snapshot.copyWith(
ollamaCloud: snapshot.ollamaCloud.copyWith(baseUrl: 'https://ollama.com'),
);
}
MultiAgentConfig _resolveMultiAgentConfig(SettingsSnapshot snapshot) {
final defaults = MultiAgentConfig.defaults();
final current = snapshot.multiAgent;

View File

@ -530,7 +530,7 @@ class _SettingsPageState extends State<SettingsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('云端 Ollama', 'Ollama Cloud'),
appText('Ollama Cloud', 'Ollama Cloud'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),

View File

@ -16,6 +16,7 @@ class ArisLlmChatClient {
ArisLlmChatClient({
ArisProcessStarter? processStarter,
ArisBridgeLocator? bridgeLocator,
Duration rpcTimeout = const Duration(minutes: 2),
}) : _processStarter =
processStarter ??
((executable, arguments, {environment, workingDirectory}) {
@ -26,10 +27,12 @@ class ArisLlmChatClient {
workingDirectory: workingDirectory,
);
}),
_bridgeLocator = bridgeLocator ?? ArisBridgeLocator();
_bridgeLocator = bridgeLocator ?? ArisBridgeLocator(),
_rpcTimeout = rpcTimeout;
final ArisProcessStarter _processStarter;
final ArisBridgeLocator _bridgeLocator;
final Duration _rpcTimeout;
Future<String> chat({
required String endpoint,
@ -100,6 +103,7 @@ class ArisLlmChatClient {
final errorBuffer = StringBuffer();
late final StreamSubscription<String> stdoutSubscription;
late final StreamSubscription<String> stderrSubscription;
late final StreamSubscription<int> exitSubscription;
stdoutSubscription = process.stdout
.transform(utf8.decoder)
@ -108,7 +112,17 @@ class ArisLlmChatClient {
if (line.trim().isEmpty) {
return;
}
final message = jsonDecode(line) as Map<String, dynamic>;
late final Map<String, dynamic> message;
try {
message = jsonDecode(line) as Map<String, dynamic>;
} catch (error) {
if (!responseCompleter.isCompleted) {
responseCompleter.completeError(
StateError('ARIS bridge returned invalid JSON: $error'),
);
}
return;
}
if (message['id'] == 2) {
final result =
(message['result'] as Map?)?.cast<String, dynamic>() ??
@ -135,6 +149,31 @@ class ArisLlmChatClient {
stderrSubscription = process.stderr
.transform(utf8.decoder)
.listen(errorBuffer.write);
exitSubscription = process.exitCode.asStream().listen((exitCode) {
scheduleMicrotask(() {
if (responseCompleter.isCompleted) {
return;
}
final stderrText = errorBuffer.toString().trim();
if (exitCode != 0) {
responseCompleter.completeError(
StateError(
stderrText.isNotEmpty
? stderrText
: 'ARIS bridge exited with code $exitCode',
),
);
return;
}
responseCompleter.completeError(
StateError(
stderrText.isNotEmpty
? stderrText
: 'ARIS bridge closed without returning a tool result.',
),
);
});
});
void send(Object payload) {
process.stdin.writeln(jsonEncode(payload));
@ -159,10 +198,17 @@ class ArisLlmChatClient {
});
try {
return await responseCompleter.future.timeout(const Duration(minutes: 2));
return await responseCompleter.future.timeout(
_rpcTimeout,
onTimeout: () => throw TimeoutException(
'ARIS bridge timed out after ${_rpcTimeout.inSeconds}s',
_rpcTimeout,
),
);
} finally {
await stdoutSubscription.cancel();
await stderrSubscription.cancel();
await exitSubscription.cancel();
try {
process.kill();
} catch (_) {

View File

@ -659,7 +659,7 @@ class OllamaCloudConfig {
factory OllamaCloudConfig.defaults() {
return const OllamaCloudConfig(
baseUrl: 'https://ollama.svc.plus',
baseUrl: 'https://ollama.com',
organization: '',
workspace: '',
defaultModel: 'gpt-oss:120b',

View File

@ -70,10 +70,7 @@ ditto "$BRIDGE_BUILD_PATH" "$HELPER_PATH"
chmod +x "$HELPER_PATH"
echo "Re-signing bundled helper and app..."
SIGN_IDENTITY="$(codesign -dv --verbose=2 "$DIST_APP_PATH" 2>&1 | sed -n 's/^Authority=//p' | head -n 1)"
if [[ -z "$SIGN_IDENTITY" ]]; then
SIGN_IDENTITY="-"
fi
SIGN_IDENTITY="${XWORKMATE_SIGN_IDENTITY:--}"
codesign --force --sign "$SIGN_IDENTITY" --timestamp=none "$HELPER_PATH"
codesign --force --deep --sign "$SIGN_IDENTITY" --preserve-metadata=entitlements,requirements,flags,runtime --timestamp=none "$DIST_APP_PATH"

View File

@ -4,6 +4,41 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/aris_bridge.dart';
void main() {
test(
'ArisBridgeLocator prefers bundled helper inside macOS app bundle',
() async {
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-aris-bridge-bundle-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
await tempDirectory.delete(recursive: true);
}
});
final helpersDir = Directory(
'${tempDirectory.path}/XWorkmate.app/Contents/Helpers',
);
await helpersDir.create(recursive: true);
final helperFile = File('${helpersDir.path}/xworkmate-aris-bridge');
await helperFile.writeAsString('#!/bin/sh\nexit 0\n');
await Process.run('chmod', <String>['+x', helperFile.path]);
final locator = ArisBridgeLocator(
workspaceRoot: tempDirectory.path,
binaryExistsResolver: (_) async => true,
resolvedExecutableResolver: () =>
'${tempDirectory.path}/XWorkmate.app/Contents/MacOS/XWorkmate',
);
final launch = await locator.locate();
expect(launch, isNotNull);
expect(launch!.executable, helperFile.path);
expect(launch.arguments, isEmpty);
expect(launch.workingDirectory, isNull);
},
);
test(
'ArisBridgeLocator falls back to go run in the local bridge package',
() async {

View File

@ -0,0 +1,194 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/aris_bridge.dart';
import 'package:xworkmate/runtime/aris_llm_chat_client.dart';
void main() {
test(
'ArisLlmChatClient returns chat content from bridge tool result',
() async {
final client = ArisLlmChatClient(
bridgeLocator: _fixedLocator(),
processStarter: (_, args, {environment, workingDirectory}) async =>
_FakeProcess.withStdoutLines(<String>[
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': 1,
'result': <String, dynamic>{'protocolVersion': '2024-11-05'},
}),
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': 2,
'result': <String, dynamic>{
'content': <Map<String, dynamic>>[
<String, dynamic>{'type': 'text', 'text': 'review ok'},
],
},
}),
]),
);
final result = await client.chat(
endpoint: 'http://127.0.0.1:11434/v1',
apiKey: 'ollama',
model: 'qwen2.5-coder:latest',
prompt: 'hello',
);
expect(result, 'review ok');
},
);
test('ArisLlmChatClient surfaces invalid bridge JSON', () async {
final client = ArisLlmChatClient(
bridgeLocator: _fixedLocator(),
processStarter: (_, args, {environment, workingDirectory}) async =>
_FakeProcess.withStdoutLines(<String>['not-json']),
);
await expectLater(
() => client.chat(
endpoint: 'http://127.0.0.1:11434/v1',
apiKey: 'ollama',
model: 'qwen2.5-coder:latest',
prompt: 'hello',
),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('invalid JSON'),
),
),
);
});
test('ArisLlmChatClient surfaces bridge process exit stderr', () async {
final client = ArisLlmChatClient(
bridgeLocator: _fixedLocator(),
processStarter: (_, args, {environment, workingDirectory}) async =>
_FakeProcess(
stdoutLines: const <String>[],
stderrText: 'bridge failed',
exitCode: 2,
),
);
await expectLater(
() => client.claudeReview(prompt: 'review this'),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('bridge failed'),
),
),
);
});
test('ArisLlmChatClient times out when bridge never responds', () async {
final client = ArisLlmChatClient(
bridgeLocator: _fixedLocator(),
rpcTimeout: const Duration(milliseconds: 10),
processStarter: (_, args, {environment, workingDirectory}) async =>
_FakeHangingProcess(),
);
await expectLater(
() => client.chat(
endpoint: 'http://127.0.0.1:11434/v1',
apiKey: 'ollama',
model: 'qwen2.5-coder:latest',
prompt: 'hello',
),
throwsA(isA<TimeoutException>()),
);
});
}
ArisBridgeLocator _fixedLocator() {
return ArisBridgeLocator(
binaryExistsResolver: (_) async => true,
workspaceRoot: Directory.systemTemp.path,
resolvedExecutableResolver: () =>
'${Directory.systemTemp.path}/XWorkmate.app/Contents/MacOS/XWorkmate',
);
}
class _FakeProcess implements Process {
_FakeProcess({
required List<String> stdoutLines,
String stderrText = '',
int exitCode = 0,
}) : _stdout = Stream<List<int>>.fromIterable(
stdoutLines.map((line) => utf8.encode('$line\n')),
),
_stderr = Stream<List<int>>.value(utf8.encode(stderrText)),
_exitCode = Future<int>.value(exitCode),
_stdin = File(
'${Directory.systemTemp.path}/aris-llm-chat-test-${DateTime.now().microsecondsSinceEpoch}.txt',
).openWrite();
factory _FakeProcess.withStdoutLines(List<String> stdoutLines) {
return _FakeProcess(stdoutLines: stdoutLines);
}
final Stream<List<int>> _stdout;
final Stream<List<int>> _stderr;
final Future<int> _exitCode;
final IOSink _stdin;
@override
Future<int> get exitCode => _exitCode;
@override
int get pid => 1;
@override
IOSink get stdin => _stdin;
@override
Stream<List<int>> get stderr => _stderr;
@override
Stream<List<int>> get stdout => _stdout;
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => true;
}
class _FakeHangingProcess implements Process {
_FakeHangingProcess()
: _stdin = File(
'${Directory.systemTemp.path}/aris-llm-chat-hanging-${DateTime.now().microsecondsSinceEpoch}.txt',
).openWrite();
final IOSink _stdin;
final Completer<int> _exitCode = Completer<int>();
@override
Future<int> get exitCode => _exitCode.future;
@override
int get pid => 2;
@override
IOSink get stdin => _stdin;
@override
Stream<List<int>> get stderr => const Stream<List<int>>.empty();
@override
Stream<List<int>> get stdout => const Stream<List<int>>.empty();
@override
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
if (!_exitCode.isCompleted) {
_exitCode.complete(0);
}
return true;
}
}

View File

@ -6,16 +6,61 @@ import 'package:xworkmate/runtime/runtime_models.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('MultiAgentBroker supports session start, message, cancel, and close', () async {
test(
'MultiAgentBroker supports session start, message, cancel, and close',
() async {
final orchestrator = _FakeOrchestrator();
final server = MultiAgentBrokerServer(orchestrator);
await server.start();
addTearDown(server.stop);
final client = MultiAgentBrokerClient(server.wsUri!);
final firstEvents = await client
.startSession(
sessionId: 'session-1',
taskPrompt: 'first turn',
workingDirectory: '/tmp',
attachments: const <CollaborationAttachment>[],
selectedSkills: const <String>['aris'],
aiGatewayBaseUrl: '',
aiGatewayApiKey: '',
)
.toList();
final secondEvents = await client
.sendSessionMessage(
sessionId: 'session-1',
taskPrompt: 'second turn',
workingDirectory: '/tmp',
attachments: const <CollaborationAttachment>[],
selectedSkills: const <String>['aris'],
aiGatewayBaseUrl: '',
aiGatewayApiKey: '',
)
.toList();
await client.cancelSession('session-1');
await client.closeSession('session-1');
expect(orchestrator.prompts, hasLength(2));
expect(orchestrator.prompts.first, contains('first turn'));
expect(orchestrator.prompts.last, contains('first turn'));
expect(orchestrator.prompts.last, contains('second turn'));
expect(firstEvents.last.type, 'result');
expect(secondEvents.last.type, 'result');
expect(orchestrator.abortCount, 1);
},
);
test('MultiAgentBroker clears session history after close', () async {
final orchestrator = _FakeOrchestrator();
final server = MultiAgentBrokerServer(orchestrator);
await server.start();
addTearDown(server.stop);
final client = MultiAgentBrokerClient(server.wsUri!);
final firstEvents = await client
await client
.startSession(
sessionId: 'session-1',
sessionId: 'session-2',
taskPrompt: 'first turn',
workingDirectory: '/tmp',
attachments: const <CollaborationAttachment>[],
@ -23,29 +68,24 @@ void main() {
aiGatewayBaseUrl: '',
aiGatewayApiKey: '',
)
.toList();
final secondEvents = await client
.drain<void>();
await client.closeSession('session-2');
await client
.sendSessionMessage(
sessionId: 'session-1',
taskPrompt: 'second turn',
sessionId: 'session-2',
taskPrompt: 'fresh turn',
workingDirectory: '/tmp',
attachments: const <CollaborationAttachment>[],
selectedSkills: const <String>['aris'],
aiGatewayBaseUrl: '',
aiGatewayApiKey: '',
)
.toList();
await client.cancelSession('session-1');
await client.closeSession('session-1');
.drain<void>();
expect(orchestrator.prompts, hasLength(2));
expect(orchestrator.prompts.first, contains('first turn'));
expect(orchestrator.prompts.last, contains('first turn'));
expect(orchestrator.prompts.last, contains('second turn'));
expect(firstEvents.last.type, 'result');
expect(secondEvents.last.type, 'result');
expect(orchestrator.abortCount, 1);
expect(orchestrator.prompts.last, contains('fresh turn'));
expect(orchestrator.prompts.last, isNot(contains('first turn')));
});
}

View File

@ -0,0 +1,160 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/aris_bundle.dart';
import 'package:xworkmate/runtime/aris_bridge.dart';
import 'package:xworkmate/runtime/multi_agent_mounts.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
void main() {
test('ArisMountAdapter reports error when bundle is unavailable', () async {
final adapter = ArisMountAdapter(
_ThrowingArisBundleRepository(),
ArisBridgeLocator(binaryExistsResolver: (_) async => false),
);
final state = await adapter.reconcile(
config: MultiAgentConfig.defaults().copyWith(
framework: MultiAgentFramework.aris,
arisEnabled: true,
),
aiGatewayUrl: '',
);
expect(state.available, isFalse);
expect(state.discoveryState, 'error');
expect(state.syncState, 'error');
});
test(
'ArisMountAdapter reports embedded state when bundle exists but bridge is unavailable',
() async {
final tempDir = await Directory.systemTemp.createTemp(
'aris-mount-embedded-',
);
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final bundle = await _writeFakeBundle(tempDir);
final adapter = ArisMountAdapter(
_FixedArisBundleRepository(bundle),
ArisBridgeLocator(
workspaceRoot: tempDir.path,
binaryExistsResolver: (_) async => false,
),
);
final state = await adapter.reconcile(
config: MultiAgentConfig.defaults().copyWith(
framework: MultiAgentFramework.aris,
arisEnabled: true,
),
aiGatewayUrl: '',
);
expect(state.available, isTrue);
expect(state.discoveryState, 'ready');
expect(state.syncState, 'embedded');
expect(state.discoveredMcpCount, 1);
expect(state.managedMcpCount, 0);
expect(state.detail, contains('bridge is not available'));
},
);
test(
'ArisMountAdapter reports ready when bundle and bundled helper are both available',
() async {
final tempDir = await Directory.systemTemp.createTemp(
'aris-mount-ready-',
);
addTearDown(() async {
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
});
final bundle = await _writeFakeBundle(tempDir);
final helperDir = Directory(
'${tempDir.path}/XWorkmate.app/Contents/Helpers',
);
await helperDir.create(recursive: true);
final helper = File('${helperDir.path}/xworkmate-aris-bridge');
await helper.writeAsString('#!/bin/sh\nexit 0\n');
await Process.run('chmod', <String>['+x', helper.path]);
final locator = ArisBridgeLocator(
workspaceRoot: tempDir.path,
binaryExistsResolver: (_) async => false,
resolvedExecutableResolver: () =>
'${tempDir.path}/XWorkmate.app/Contents/MacOS/XWorkmate',
);
final adapter = ArisMountAdapter(
_FixedArisBundleRepository(bundle),
locator,
);
final state = await adapter.reconcile(
config: MultiAgentConfig.defaults().copyWith(
framework: MultiAgentFramework.aris,
arisEnabled: true,
),
aiGatewayUrl: '',
);
expect(state.available, isTrue);
expect(state.discoveryState, 'ready');
expect(state.syncState, 'ready');
expect(state.managedMcpCount, 1);
expect(state.detail, contains('manages llm-chat and claude-review'));
},
);
}
Future<ResolvedArisBundle> _writeFakeBundle(Directory root) async {
final skillsDir = Directory('${root.path}/skills/idea-discovery');
await skillsDir.create(recursive: true);
await File('${skillsDir.path}/SKILL.md').writeAsString('# idea\n');
await File('${root.path}/mcp-server.py').writeAsString('print("ok")\n');
await File('${root.path}/requirements.txt').writeAsString('httpx\n');
return ResolvedArisBundle(
rootPath: root.path,
manifest: ArisBundleManifest(
schemaVersion: 1,
name: 'ARIS',
bundleVersion: 'test',
upstreamRepository: 'https://example.com/aris',
upstreamCommit: 'abc123',
llmChatServerPath: 'mcp-server.py',
llmChatRequirementsPath: 'requirements.txt',
roleSkills: const <MultiAgentRole, List<String>>{
MultiAgentRole.architect: <String>['skills/idea-discovery/SKILL.md'],
MultiAgentRole.engineer: <String>[],
MultiAgentRole.testerDoc: <String>[],
},
codexRoleSkills: const <MultiAgentRole, List<String>>{
MultiAgentRole.architect: <String>[],
MultiAgentRole.engineer: <String>[],
MultiAgentRole.testerDoc: <String>[],
},
),
);
}
class _FixedArisBundleRepository extends ArisBundleRepository {
_FixedArisBundleRepository(this._bundle);
final ResolvedArisBundle _bundle;
@override
Future<ResolvedArisBundle> ensureReady() async => _bundle;
@override
Future<int> countSkillFiles() async => 1;
}
class _ThrowingArisBundleRepository extends ArisBundleRepository {
@override
Future<ResolvedArisBundle> ensureReady() async {
throw StateError('missing bundle');
}
}