diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 6e164432..384611bc 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -205,6 +205,8 @@ class AppController extends ChangeNotifier { >{}; final Map _aiGatewayStreamingTextBySession = {}; + final Map _singleAgentRuntimeModelBySession = + {}; final Map _aiGatewayStreamingClients = {}; final Set _aiGatewayPendingSessionKeys = {}; @@ -517,6 +519,19 @@ class AppController extends ChangeNotifier { String assistantModelForSession(String sessionKey) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); final target = assistantExecutionTargetForSession(normalizedSessionKey); + if (target == AssistantExecutionTarget.singleAgent) { + if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { + final recordModel = + _assistantThreadRecords[normalizedSessionKey]?.assistantModelId + .trim() ?? + ''; + if (recordModel.isNotEmpty) { + return recordModel; + } + return resolvedAiGatewayModel; + } + return singleAgentRuntimeModelForSession(normalizedSessionKey); + } final recordModel = _assistantThreadRecords[normalizedSessionKey]?.assistantModelId .trim() ?? @@ -524,10 +539,6 @@ class AppController extends ChangeNotifier { if (recordModel.isNotEmpty) { return recordModel; } - if (target == AssistantExecutionTarget.singleAgent && - singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { - return resolvedAiGatewayModel; - } return _resolvedAssistantModelForTarget(target); } @@ -603,8 +614,20 @@ class AppController extends ChangeNotifier { bool get currentSingleAgentShouldSuggestAutoSwitch => singleAgentShouldSuggestAutoSwitchForSession(currentSessionKey); + String singleAgentRuntimeModelForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _singleAgentRuntimeModelBySession[normalizedSessionKey]?.trim() ?? ''; + } + + String get currentSingleAgentRuntimeModel => + singleAgentRuntimeModelForSession(currentSessionKey); + String singleAgentModelDisplayLabelForSession(String sessionKey) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final runtimeModel = singleAgentRuntimeModelForSession(normalizedSessionKey); + if (runtimeModel.isNotEmpty) { + return runtimeModel; + } final model = assistantModelForSession(normalizedSessionKey); if (model.isNotEmpty) { return model; @@ -624,6 +647,21 @@ class AppController extends ChangeNotifier { String get currentSingleAgentModelDisplayLabel => singleAgentModelDisplayLabelForSession(currentSessionKey); + bool singleAgentShouldShowModelControlForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (assistantExecutionTargetForSession(normalizedSessionKey) != + AssistantExecutionTarget.singleAgent) { + return true; + } + if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { + return true; + } + return singleAgentRuntimeModelForSession(normalizedSessionKey).isNotEmpty; + } + + bool get currentSingleAgentShouldShowModelControl => + singleAgentShouldShowModelControlForSession(currentSessionKey); + List get singleAgentProviderOptions => const [ SingleAgentProvider.auto, @@ -1786,6 +1824,7 @@ class AppController extends ChangeNotifier { if (singleAgentProviderForSession(sessionKey) == sanitizedProvider) { return; } + _singleAgentRuntimeModelBySession.remove(sessionKey); _upsertAssistantThreadRecord( sessionKey, singleAgentProvider: sanitizedProvider, @@ -1838,6 +1877,9 @@ class AppController extends ChangeNotifier { }) async { final resolvedTarget = _sanitizeExecutionTarget(target); final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (resolvedTarget != AssistantExecutionTarget.singleAgent) { + _singleAgentRuntimeModelBySession.remove(normalizedSessionKey); + } if (!matchesSessionKey( normalizedSessionKey, _sessionsController.currentSessionKey, @@ -3209,6 +3251,10 @@ class AppController extends ChangeNotifier { configuredCodexCliPath: configuredCodexCliPath, ), ); + final resolvedRuntimeModel = result.resolvedModel.trim(); + if (resolvedRuntimeModel.isNotEmpty) { + _singleAgentRuntimeModelBySession[sessionKey] = resolvedRuntimeModel; + } _clearAiGatewayStreamingText(sessionKey); if (result.aborted) { final partial = result.output.trim(); diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 2c9a9141..1bdb895c 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -459,6 +459,9 @@ class _AssistantPageState extends State { inputController: _inputController, focusNode: _composerFocusNode, thinkingLabel: _thinkingLabel, + showModelControl: !controller.isSingleAgentMode + ? true + : controller.currentSingleAgentShouldShowModelControl, modelLabel: controller.isSingleAgentMode ? controller.currentSingleAgentModelDisplayLabel : controller.resolvedAssistantModel.isEmpty @@ -1617,6 +1620,7 @@ class _AssistantLowerPane extends StatelessWidget { required this.inputController, required this.focusNode, required this.thinkingLabel, + required this.showModelControl, required this.modelLabel, required this.modelOptions, required this.attachments, @@ -1638,6 +1642,7 @@ class _AssistantLowerPane extends StatelessWidget { final TextEditingController inputController; final FocusNode focusNode; final String thinkingLabel; + final bool showModelControl; final String modelLabel; final List modelOptions; final List<_ComposerAttachment> attachments; @@ -1665,6 +1670,7 @@ class _AssistantLowerPane extends StatelessWidget { inputController: inputController, focusNode: focusNode, thinkingLabel: thinkingLabel, + showModelControl: showModelControl, modelLabel: modelLabel, modelOptions: modelOptions, attachments: attachments, @@ -2495,6 +2501,7 @@ class _ComposerBar extends StatefulWidget { required this.inputController, required this.focusNode, required this.thinkingLabel, + required this.showModelControl, required this.modelLabel, required this.modelOptions, required this.attachments, @@ -2516,6 +2523,7 @@ class _ComposerBar extends StatefulWidget { final TextEditingController inputController; final FocusNode focusNode; final String thinkingLabel; + final bool showModelControl; final String modelLabel; final List modelOptions; final List<_ComposerAttachment> attachments; @@ -2899,43 +2907,45 @@ class _ComposerBarState extends State<_ComposerBar> { maxLabelWidth: 120, ), ), - const SizedBox(width: 6), - widget.modelOptions.isEmpty - ? _ComposerToolbarChip( - key: const Key('assistant-model-button'), - icon: Icons.bolt_rounded, - label: widget.modelLabel, - showChevron: false, - maxLabelWidth: 140, - ) - : PopupMenuButton( - key: const Key('assistant-model-button'), - tooltip: appText('模型', 'Model'), - onSelected: widget.onModelChanged, - itemBuilder: (context) => widget.modelOptions - .map( - (value) => PopupMenuItem( - value: value, - child: Row( - children: [ - Expanded(child: Text(value)), - if (value == widget.modelLabel) - const Icon( - Icons.check_rounded, - size: 18, - ), - ], - ), - ), - ) - .toList(), - child: _ComposerToolbarChip( + if (widget.showModelControl) ...[ + const SizedBox(width: 6), + widget.modelOptions.isEmpty + ? _ComposerToolbarChip( + key: const Key('assistant-model-button'), icon: Icons.bolt_rounded, label: widget.modelLabel, - showChevron: true, + showChevron: false, maxLabelWidth: 140, + ) + : PopupMenuButton( + key: const Key('assistant-model-button'), + tooltip: appText('模型', 'Model'), + onSelected: widget.onModelChanged, + itemBuilder: (context) => widget.modelOptions + .map( + (value) => PopupMenuItem( + value: value, + child: Row( + children: [ + Expanded(child: Text(value)), + if (value == widget.modelLabel) + const Icon( + Icons.check_rounded, + size: 18, + ), + ], + ), + ), + ) + .toList(), + child: _ComposerToolbarChip( + icon: Icons.bolt_rounded, + label: widget.modelLabel, + showChevron: true, + maxLabelWidth: 140, + ), ), - ), + ], const SizedBox(width: 6), PopupMenuButton( key: const Key('assistant-thinking-button'), diff --git a/lib/runtime/direct_single_agent_app_server_client.dart b/lib/runtime/direct_single_agent_app_server_client.dart index e83799aa..2c1fbc6a 100644 --- a/lib/runtime/direct_single_agent_app_server_client.dart +++ b/lib/runtime/direct_single_agent_app_server_client.dart @@ -35,12 +35,14 @@ class DirectSingleAgentRunResult { required this.output, required this.errorMessage, this.aborted = false, + this.resolvedModel = '', }); final bool success; final String output; final String errorMessage; final bool aborted; + final String resolvedModel; } class DirectSingleAgentRunRequest { @@ -166,6 +168,7 @@ class DirectSingleAgentAppServerClient { ); final output = StringBuffer(); + String resolvedModel = ''; final completion = Completer(); late final StreamSubscription> subscription; subscription = connection.notifications.listen( @@ -189,6 +192,7 @@ class DirectSingleAgentAppServerClient { success: true, output: output.toString(), errorMessage: '', + resolvedModel: resolvedModel, ), ); return; @@ -206,6 +210,7 @@ class DirectSingleAgentAppServerClient { success: false, output: output.toString(), aborted: aborted, + resolvedModel: resolvedModel, errorMessage: params['message']?.toString() ?? params['error']?.toString() ?? @@ -222,6 +227,7 @@ class DirectSingleAgentAppServerClient { output: output.toString(), errorMessage: error.toString(), aborted: _abortedSessions.contains(normalizedSessionId), + resolvedModel: resolvedModel, ), ); } @@ -236,6 +242,7 @@ class DirectSingleAgentAppServerClient { ? 'Single-agent app-server run aborted.' : 'Single-agent app-server connection closed before completion.', aborted: _abortedSessions.contains(normalizedSessionId), + resolvedModel: resolvedModel, ), ); } @@ -243,7 +250,7 @@ class DirectSingleAgentAppServerClient { ); try { - await connection.request( + final started = await connection.request( 'turn/start', params: { 'threadId': threadId, @@ -253,6 +260,7 @@ class DirectSingleAgentAppServerClient { }, }, ); + resolvedModel = _extractModel(started) ?? resolvedModel; return await completion.future.timeout( const Duration(minutes: 10), onTimeout: () => DirectSingleAgentRunResult( @@ -260,6 +268,7 @@ class DirectSingleAgentAppServerClient { output: output.toString(), errorMessage: 'Single-agent app-server request timed out.', aborted: _abortedSessions.contains(normalizedSessionId), + resolvedModel: resolvedModel, ), ); } finally { @@ -271,6 +280,7 @@ class DirectSingleAgentAppServerClient { output: '', errorMessage: error.toString(), aborted: _abortedSessions.contains(normalizedSessionId), + resolvedModel: '', ); } finally { _activeConnections.remove(normalizedSessionId); @@ -325,7 +335,7 @@ class DirectSingleAgentAppServerClient { if (workingDirectory.trim().isNotEmpty) 'cwd': workingDirectory, }, ); - final resumedId = resumed['id']?.toString().trim() ?? existingThreadId; + final resumedId = _extractThreadId(resumed) ?? existingThreadId; _threadIds[sessionId] = resumedId; return resumedId; } catch (_) { @@ -340,7 +350,7 @@ class DirectSingleAgentAppServerClient { if (model.trim().isNotEmpty) 'model': model.trim(), }, ); - final threadId = created['id']?.toString().trim() ?? ''; + final threadId = _extractThreadId(created) ?? ''; if (threadId.isEmpty) { throw StateError('Single-agent app-server returned an empty thread id.'); } @@ -348,6 +358,27 @@ class DirectSingleAgentAppServerClient { return threadId; } + String? _extractThreadId(Map payload) { + final topLevelId = payload['id']?.toString().trim() ?? ''; + if (topLevelId.isNotEmpty) { + return topLevelId; + } + final thread = _asMap(payload['thread']); + final nestedId = thread['id']?.toString().trim() ?? ''; + if (nestedId.isNotEmpty) { + return nestedId; + } + return null; + } + + String? _extractModel(Map payload) { + final model = payload['model']?.toString().trim() ?? ''; + if (model.isNotEmpty) { + return model; + } + return null; + } + Uri? _resolveWebSocketEndpoint(SingleAgentProvider provider) { final base = endpointResolver(provider); if (base == null) { diff --git a/lib/runtime/single_agent_runner.dart b/lib/runtime/single_agent_runner.dart index ef914a98..0bf528a7 100644 --- a/lib/runtime/single_agent_runner.dart +++ b/lib/runtime/single_agent_runner.dart @@ -55,6 +55,7 @@ class SingleAgentRunResult { required this.shouldFallbackToAiChat, this.aborted = false, this.fallbackReason, + this.resolvedModel = '', }); final SingleAgentProvider provider; @@ -64,6 +65,7 @@ class SingleAgentRunResult { final bool shouldFallbackToAiChat; final bool aborted; final String? fallbackReason; + final String resolvedModel; } abstract class SingleAgentRunner { @@ -166,6 +168,7 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { errorMessage: result.errorMessage, shouldFallbackToAiChat: !result.success && result.output.isEmpty, aborted: result.aborted, + resolvedModel: result.resolvedModel, fallbackReason: !result.success ? 'Single-agent app-server run failed: ${result.errorMessage}' : null, @@ -178,6 +181,7 @@ class DefaultSingleAgentRunner implements SingleAgentRunner { success: false, errorMessage: error.toString(), shouldFallbackToAiChat: shouldFallback, + resolvedModel: '', fallbackReason: shouldFallback ? '${request.provider.label} provider is unavailable from the direct app-server endpoint.' : null, diff --git a/test/runtime/app_controller_ai_gateway_chat_suite.dart b/test/runtime/app_controller_ai_gateway_chat_suite.dart index 2b1bd486..a73a4906 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite.dart @@ -349,6 +349,7 @@ void main() { success: true, errorMessage: '', shouldFallbackToAiChat: false, + resolvedModel: 'codex-sonnet', ), ); final controller = AppController( @@ -375,6 +376,8 @@ void main() { expect(runner.resolveCalls, 1); expect(runner.runCalls, 1); expect(runner.lastRequest?.provider, SingleAgentProvider.codex); + expect(runner.lastRequest?.model, isEmpty); + expect(controller.currentSingleAgentModelDisplayLabel, 'codex-sonnet'); expect( controller.chatMessages.any( (message) => diff --git a/test/runtime/app_controller_ai_gateway_models_suite.dart b/test/runtime/app_controller_ai_gateway_models_suite.dart index 88c62e56..67b2f0a7 100644 --- a/test/runtime/app_controller_ai_gateway_models_suite.dart +++ b/test/runtime/app_controller_ai_gateway_models_suite.dart @@ -136,6 +136,7 @@ void main() { expect(controller.currentSingleAgentHasResolvedProvider, isTrue); expect(controller.currentSingleAgentUsesAiChatFallback, isFalse); + expect(controller.currentSingleAgentShouldShowModelControl, isFalse); expect(controller.assistantModelChoices, isEmpty); expect(controller.resolvedAssistantModel, isEmpty); }, diff --git a/test/runtime/direct_single_agent_app_server_suite.dart b/test/runtime/direct_single_agent_app_server_suite.dart index 06bfa5f8..f451b181 100644 --- a/test/runtime/direct_single_agent_app_server_suite.dart +++ b/test/runtime/direct_single_agent_app_server_suite.dart @@ -52,6 +52,7 @@ void main() { expect(result.success, isTrue); expect(result.output, 'hello world from app server'); + expect(result.resolvedModel, 'codex-sonnet'); expect(deltas.join(), 'hello world from app server'); expect( server.methods, @@ -87,14 +88,47 @@ void main() { expect(result.aborted, isTrue); expect(server.methods, contains('turn/interrupt')); }); + + test( + 'accepts nested thread objects returned by codex app-server', + () async { + final server = await _FakeAppServer.start(nestedThreadResult: true); + addTearDown(server.close); + + final client = DirectSingleAgentAppServerClient( + endpointResolver: (_) => server.baseHttpUri, + ); + addTearDown(client.dispose); + + final result = await client.run( + const DirectSingleAgentRunRequest( + sessionId: 'session-nested', + provider: SingleAgentProvider.codex, + prompt: 'hello nested world', + model: 'qwen2.5-coder:latest', + workingDirectory: '/tmp', + gatewayToken: '', + ), + ); + + expect(result.success, isTrue); + expect(result.output, 'hello world from app server'); + expect(result.resolvedModel, 'codex-sonnet'); + }, + ); }); } class _FakeAppServer { - _FakeAppServer._(this._server, {required this.delayCompletion}); + _FakeAppServer._( + this._server, { + required this.delayCompletion, + required this.nestedThreadResult, + }); final HttpServer _server; final bool delayCompletion; + final bool nestedThreadResult; final List methods = []; final List authorizationHeaders = []; final Map> _methodWaiters = @@ -104,9 +138,16 @@ class _FakeAppServer { int get port => _server.port; Uri get baseHttpUri => Uri.parse('http://127.0.0.1:${_server.port}'); - static Future<_FakeAppServer> start({bool delayCompletion = false}) async { + static Future<_FakeAppServer> start({ + bool delayCompletion = false, + bool nestedThreadResult = false, + }) async { final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - final fake = _FakeAppServer._(server, delayCompletion: delayCompletion); + final fake = _FakeAppServer._( + server, + delayCompletion: delayCompletion, + nestedThreadResult: nestedThreadResult, + ); unawaited(fake._listen()); return fake; } @@ -166,28 +207,46 @@ class _FakeAppServer { break; case 'thread/start': _threadCounter += 1; + final result = nestedThreadResult + ? { + 'thread': { + 'id': 'thread-$_threadCounter', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }, + } + : { + 'id': 'thread-$_threadCounter', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }; socket.add( jsonEncode({ 'jsonrpc': '2.0', 'id': id, - 'result': { - 'id': 'thread-$_threadCounter', - 'path': params['cwd'] ?? '/tmp', - 'ephemeral': false, - }, + 'result': result, }), ); break; case 'thread/resume': + final result = nestedThreadResult + ? { + 'thread': { + 'id': params['threadId'] ?? 'thread-resumed', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }, + } + : { + 'id': params['threadId'] ?? 'thread-resumed', + 'path': params['cwd'] ?? '/tmp', + 'ephemeral': false, + }; socket.add( jsonEncode({ 'jsonrpc': '2.0', 'id': id, - 'result': { - 'id': params['threadId'] ?? 'thread-resumed', - 'path': params['cwd'] ?? '/tmp', - 'ephemeral': false, - }, + 'result': result, }), ); break; @@ -201,6 +260,7 @@ class _FakeAppServer { 'id': 'turn-1', 'threadId': threadId, 'status': 'started', + 'model': 'codex-sonnet', }, }), );