Fix single-agent ACP model ownership

This commit is contained in:
Haitao Pan 2026-03-24 00:11:43 +08:00
parent 1807744575
commit adb93ed7ff
7 changed files with 208 additions and 53 deletions

View File

@ -205,6 +205,8 @@ class AppController extends ChangeNotifier {
<String, List<GatewayChatMessage>>{};
final Map<String, String> _aiGatewayStreamingTextBySession =
<String, String>{};
final Map<String, String> _singleAgentRuntimeModelBySession =
<String, String>{};
final Map<String, HttpClient> _aiGatewayStreamingClients =
<String, HttpClient>{};
final Set<String> _aiGatewayPendingSessionKeys = <String>{};
@ -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<SingleAgentProvider> get singleAgentProviderOptions =>
const <SingleAgentProvider>[
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();

View File

@ -459,6 +459,9 @@ class _AssistantPageState extends State<AssistantPage> {
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<String> 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<String> 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<String>(
key: const Key('assistant-model-button'),
tooltip: appText('模型', 'Model'),
onSelected: widget.onModelChanged,
itemBuilder: (context) => widget.modelOptions
.map(
(value) => PopupMenuItem<String>(
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<String>(
key: const Key('assistant-model-button'),
tooltip: appText('模型', 'Model'),
onSelected: widget.onModelChanged,
itemBuilder: (context) => widget.modelOptions
.map(
(value) => PopupMenuItem<String>(
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<String>(
key: const Key('assistant-thinking-button'),

View File

@ -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<DirectSingleAgentRunResult>();
late final StreamSubscription<Map<String, dynamic>> 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: <String, dynamic>{
'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<String, dynamic> 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<String, dynamic> 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) {

View File

@ -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,

View File

@ -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) =>

View File

@ -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);
},

View File

@ -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<String> methods = <String>[];
final List<String> authorizationHeaders = <String>[];
final Map<String, Completer<void>> _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
? <String, dynamic>{
'thread': <String, dynamic>{
'id': 'thread-$_threadCounter',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
},
}
: <String, dynamic>{
'id': 'thread-$_threadCounter',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
};
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'id': 'thread-$_threadCounter',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
},
'result': result,
}),
);
break;
case 'thread/resume':
final result = nestedThreadResult
? <String, dynamic>{
'thread': <String, dynamic>{
'id': params['threadId'] ?? 'thread-resumed',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
},
}
: <String, dynamic>{
'id': params['threadId'] ?? 'thread-resumed',
'path': params['cwd'] ?? '/tmp',
'ephemeral': false,
};
socket.add(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': <String, dynamic>{
'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',
},
}),
);