Stabilize ARIS packaging and Ollama Cloud settings
This commit is contained in:
parent
7a58db73bf
commit
78c2dd6dc2
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 (_) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
194
test/runtime/aris_llm_chat_client_test.dart
Normal file
194
test/runtime/aris_llm_chat_client_test.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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')));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
160
test/runtime/multi_agent_mounts_test.dart
Normal file
160
test/runtime/multi_agent_mounts_test.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user