diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 6a82a4a2..770385cb 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -260,9 +260,16 @@ extension AppControllerDesktopThreadActions on AppController { currentSessionKey, executionTarget: currentTarget, ); - final workspacePath = assistantWorkspacePathForSession( + var workspacePath = assistantWorkspacePathForSession( currentSessionKey, ).trim(); + if (workspacePath.isEmpty) { + await tryBindWorkspaceForOnlyChatFallbackInternal( + currentSessionKey, + currentTarget, + ); + workspacePath = assistantWorkspacePathForSession(currentSessionKey).trim(); + } if (workspacePath.isEmpty) { final error = StateError( appText( @@ -586,4 +593,60 @@ extension AppControllerDesktopThreadActions on AppController { } return value.isEmpty ? null : value; } + + Future tryBindWorkspaceForOnlyChatFallbackInternal( + String sessionKey, + AssistantExecutionTarget currentTarget, + ) async { + if (currentTarget != AssistantExecutionTarget.singleAgent && + currentTarget != AssistantExecutionTarget.auto) { + return; + } + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + if (!singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { + return; + } + if (assistantWorkspacePathForSession(normalizedSessionKey).trim().isNotEmpty) { + return; + } + + final candidateRoots = { + settings.workspacePath.trim(), + Directory.current.path.trim(), + settings.remoteProjectRoot.trim(), + }.where((item) => item.isNotEmpty); + for (final root in candidateRoots) { + final threadWorkspace = + '${trimTrailingPathSeparatorInternal(root)}/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(normalizedSessionKey)}'; + if (!ensureLocalWorkspaceDirectoryInternal(threadWorkspace)) { + continue; + } + final existing = assistantThreadRecordsInternal[normalizedSessionKey]; + upsertTaskThreadInternal( + normalizedSessionKey, + workspaceBinding: WorkspaceBinding( + workspaceId: normalizedSessionKey, + workspaceKind: WorkspaceKind.localFs, + workspacePath: threadWorkspace, + displayPath: threadWorkspace, + writable: existing?.workspaceBinding.writable ?? true, + ), + lifecycleState: + (existing?.lifecycleState ?? + const ThreadLifecycleState( + archived: false, + status: 'ready', + lastRunAtMs: null, + lastResultCode: null, + )) + .copyWith(status: 'ready'), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await flushAssistantThreadPersistenceInternal(); + recomputeTasksInternal(); + return; + } + } } diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart index 8cfdf409..b1be1af7 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart @@ -443,6 +443,65 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); }, ); + + test( + 'AppController auto-binds a thread workspace in AI Chat fallback when workspace root is missing', + () async { + final tempDirectory = await createTempDirectoryInternal( + 'xworkmate-single-agent-fallback-missing-workspace-', + ); + final server = await FakeAiGatewayServerInternal.start( + responseMode: AiGatewayResponseModeInternal.json, + ); + addTearDown(() async { + await server.close(); + }); + + final store = createStoreFromTempDirectoryInternal(tempDirectory); + final client = FallbackOnlyGoAgentCoreClientInternal(); + final controller = await createAppControllerInternal( + store: store, + availableSingleAgentProvidersOverride: const [], + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + goAgentCoreClient: client, + ); + + await controller.settingsController.saveAiGatewayApiKey('live-key'); + await controller.saveSettings( + controller.settings.copyWith( + workspacePath: '', + aiGateway: controller.settings.aiGateway.copyWith( + baseUrl: server.baseUrl, + availableModels: const ['moonshotai/kimi-k2.5'], + selectedModels: const ['moonshotai/kimi-k2.5'], + ), + defaultModel: 'moonshotai/kimi-k2.5', + ), + refreshAfterSave: false, + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + await controller.setSingleAgentProvider(SingleAgentProvider.opencode); + + await controller.sendChatMessage('你好', thinking: 'low'); + + final workspacePath = controller.assistantWorkspacePathForSession( + controller.currentSessionKey, + ); + expect(client.capabilitiesCalls, greaterThanOrEqualTo(1)); + expect(client.executeCalls, 0); + expect(server.requestCount, 1); + expect(workspacePath, isNotEmpty); + expect( + workspacePath, + contains('.xworkmate/threads/'), + ); + }, + ); }); group('Single Agent workspace resolution', () {