diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 76b45d83..130a3e85 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -1358,7 +1358,8 @@ class AppController extends ChangeNotifier { return false; } final normalizedRef = workspaceRef?.trim() ?? ''; - if (normalizedRef.isEmpty || workspaceRefKind != WorkspaceRefKind.localPath) { + if (normalizedRef.isEmpty || + workspaceRefKind != WorkspaceRefKind.localPath) { return false; } final expectedDefault = _defaultWorkspaceRefForSession( @@ -3630,14 +3631,13 @@ class AppController extends ChangeNotifier { continue; } final titleFromSettings = assistantCustomTaskTitle(sessionKey); - final shouldMigrateWorkspaceRef = - _shouldMigrateWorkspaceRef( - sessionKey, - executionTarget: - record.executionTarget ?? settings.assistantExecutionTarget, - workspaceRef: record.workspaceRef, - workspaceRefKind: record.workspaceRefKind, - ); + final shouldMigrateWorkspaceRef = _shouldMigrateWorkspaceRef( + sessionKey, + executionTarget: + record.executionTarget ?? settings.assistantExecutionTarget, + workspaceRef: record.workspaceRef, + workspaceRefKind: record.workspaceRefKind, + ); final normalizedRecord = record.copyWith( sessionKey: sessionKey, title: titleFromSettings.isEmpty @@ -4442,7 +4442,10 @@ class AppController extends ChangeNotifier { return candidate; } - String? _resolveLocalAssistantWorkingDirectoryForSession(String sessionKey) { + String? _resolveLocalAssistantWorkingDirectoryForSession( + String sessionKey, { + bool requireLocalExistence = true, + }) { if (assistantWorkspaceRefKindForSession(sessionKey) != WorkspaceRefKind.localPath) { return null; @@ -4452,10 +4455,19 @@ class AppController extends ChangeNotifier { return null; } final directory = Directory(candidate); - return directory.existsSync() ? directory.path : null; + if (directory.existsSync()) { + return directory.path; + } + if (requireLocalExistence) { + return null; + } + return candidate; } - String? _resolveSingleAgentWorkingDirectoryForSession(String sessionKey) { + String? _resolveSingleAgentWorkingDirectoryForSession( + String sessionKey, { + SingleAgentProvider? provider, + }) { final workspaceKind = assistantWorkspaceRefKindForSession(sessionKey); if (workspaceKind == WorkspaceRefKind.objectStore) { return null; @@ -4463,7 +4475,35 @@ class AppController extends ChangeNotifier { if (workspaceKind == WorkspaceRefKind.remotePath) { return _assistantWorkingDirectoryForSession(sessionKey); } - return _resolveLocalAssistantWorkingDirectoryForSession(sessionKey); + return _resolveLocalAssistantWorkingDirectoryForSession( + sessionKey, + requireLocalExistence: + provider == null || _singleAgentProviderRequiresLocalPath(provider), + ); + } + + bool _singleAgentProviderRequiresLocalPath(SingleAgentProvider provider) { + final endpoint = _resolveSingleAgentEndpoint(provider); + if (endpoint == null) { + return true; + } + final scheme = endpoint.scheme.trim().toLowerCase(); + if (scheme == 'wss' || scheme == 'https') { + return false; + } + final host = endpoint.host.trim(); + if (host.isEmpty) { + return true; + } + final address = InternetAddress.tryParse(host); + if (address != null) { + return !(address.isLoopback || address.type == InternetAddressType.unix); + } + final normalizedHost = host.toLowerCase(); + if (normalizedHost == 'localhost') { + return true; + } + return false; } void _registerCodexExternalProvider() { diff --git a/lib/app/app_controller_desktop_single_agent.dart b/lib/app/app_controller_desktop_single_agent.dart index 5cc5843c..e0ba1cb4 100644 --- a/lib/app/app_controller_desktop_single_agent.dart +++ b/lib/app/app_controller_desktop_single_agent.dart @@ -94,7 +94,10 @@ extension AppControllerDesktopSingleAgent on AppController { model: assistantModelForSession(sessionKey), gatewayToken: gatewayToken, workingDirectory: - _resolveSingleAgentWorkingDirectoryForSession(sessionKey) ?? + _resolveSingleAgentWorkingDirectoryForSession( + sessionKey, + provider: provider, + ) ?? Directory.current.path, attachments: localAttachments, selectedSkills: selectedSkills, diff --git a/lib/runtime/direct_single_agent_app_server_client.dart b/lib/runtime/direct_single_agent_app_server_client.dart index adba46ea..5572839b 100644 --- a/lib/runtime/direct_single_agent_app_server_client.dart +++ b/lib/runtime/direct_single_agent_app_server_client.dart @@ -388,6 +388,7 @@ class _DirectSingleAgentWebSocketTransport { final Map _activeConnections = {}; final Map _threadIds = {}; + final Map _threadWorkingDirectories = {}; final Set _abortedSessions = {}; Future probe(Uri endpoint, {required String gatewayToken}) async { @@ -610,32 +611,53 @@ class _DirectSingleAgentWebSocketTransport { required String workingDirectory, required String model, }) async { + final normalizedWorkingDirectory = workingDirectory.trim(); final existingThreadId = _threadIds[sessionId]?.trim() ?? ''; + final existingWorkingDirectory = + _threadWorkingDirectories[sessionId]?.trim() ?? ''; + final canReuseExistingThread = + existingThreadId.isNotEmpty && + (normalizedWorkingDirectory.isEmpty || + (existingWorkingDirectory.isNotEmpty && + existingWorkingDirectory == normalizedWorkingDirectory)); if (existingThreadId.isNotEmpty) { + if (!canReuseExistingThread) { + _threadIds.remove(sessionId); + _threadWorkingDirectories.remove(sessionId); + } + } + if (canReuseExistingThread) { try { final resumed = await connection.request( 'thread/resume', params: { 'threadId': existingThreadId, - if (workingDirectory.trim().isNotEmpty) 'cwd': workingDirectory, + if (normalizedWorkingDirectory.isNotEmpty) + 'cwd': normalizedWorkingDirectory, }, ); final resumedId = _extractThreadId(resumed) ?? existingThreadId; + final resumedWorkingDirectory = + _extractThreadPath(resumed)?.trim() ?? normalizedWorkingDirectory; _threadIds[sessionId] = resumedId; + if (resumedWorkingDirectory.isNotEmpty) { + _threadWorkingDirectories[sessionId] = resumedWorkingDirectory; + } return _ResolvedDirectThread( threadId: resumedId, - workingDirectory: - _extractThreadPath(resumed)?.trim() ?? workingDirectory.trim(), + workingDirectory: resumedWorkingDirectory, ); } catch (_) { _threadIds.remove(sessionId); + _threadWorkingDirectories.remove(sessionId); } } final created = await connection.request( 'thread/start', params: { - if (workingDirectory.trim().isNotEmpty) 'cwd': workingDirectory, + if (normalizedWorkingDirectory.isNotEmpty) + 'cwd': normalizedWorkingDirectory, if (model.trim().isNotEmpty) 'model': model.trim(), }, ); @@ -643,17 +665,22 @@ class _DirectSingleAgentWebSocketTransport { if (threadId.isEmpty) { throw StateError('Single-agent app-server returned an empty thread id.'); } + final createdWorkingDirectory = + _extractThreadPath(created)?.trim() ?? normalizedWorkingDirectory; _threadIds[sessionId] = threadId; + if (createdWorkingDirectory.isNotEmpty) { + _threadWorkingDirectories[sessionId] = createdWorkingDirectory; + } return _ResolvedDirectThread( threadId: threadId, - workingDirectory: - _extractThreadPath(created)?.trim() ?? workingDirectory.trim(), + workingDirectory: createdWorkingDirectory, ); } } class _DirectSingleAgentRestTransport { final Map _restSessionIds = {}; + final Map _restSessionWorkingDirectories = {}; final Set _abortedSessions = {}; Future probe(Uri base, {required String gatewayToken}) async { @@ -930,15 +957,28 @@ class _DirectSingleAgentRestTransport { required String workingDirectory, required String gatewayToken, }) async { + final normalizedWorkingDirectory = workingDirectory.trim(); final existing = _restSessionIds[sessionId]?.trim() ?? ''; if (existing.isNotEmpty) { - return existing; + final existingWorkingDirectory = + _restSessionWorkingDirectories[sessionId]?.trim() ?? ''; + final canReuseExistingSession = + normalizedWorkingDirectory.isEmpty || + (existingWorkingDirectory.isNotEmpty && + existingWorkingDirectory == normalizedWorkingDirectory); + if (canReuseExistingSession) { + return existing; + } + _restSessionIds.remove(sessionId); + _restSessionWorkingDirectories.remove(sessionId); } final created = await _postJson( _buildRestUri( base, '/session', - queryParameters: {'directory': workingDirectory}, + queryParameters: { + 'directory': normalizedWorkingDirectory, + }, ), body: {'title': sessionId}, gatewayToken: gatewayToken, @@ -948,6 +988,9 @@ class _DirectSingleAgentRestTransport { throw StateError('OpenCode REST endpoint returned an empty session id.'); } _restSessionIds[sessionId] = createdId; + if (normalizedWorkingDirectory.isNotEmpty) { + _restSessionWorkingDirectories[sessionId] = normalizedWorkingDirectory; + } return createdId; } diff --git a/test/runtime/direct_single_agent_app_server_suite.dart b/test/runtime/direct_single_agent_app_server_suite.dart index 4ff8f896..a51231d3 100644 --- a/test/runtime/direct_single_agent_app_server_suite.dart +++ b/test/runtime/direct_single_agent_app_server_suite.dart @@ -96,6 +96,48 @@ void main() { expect(server.authorizationHeaders, contains('Bearer token-1')); }); + test( + 'starts a new websocket thread when working directory changes for a session', + () async { + final server = await _FakeAppServer.start(); + addTearDown(server.close); + + final client = DirectSingleAgentAppServerClient( + endpointResolver: (_) => server.baseHttpUri, + ); + addTearDown(client.dispose); + + final first = await client.run( + const DirectSingleAgentRunRequest( + sessionId: 'session-cwd-change', + provider: SingleAgentProvider.opencode, + prompt: 'first turn', + model: 'gpt-4.1', + workingDirectory: '/tmp/a', + gatewayToken: '', + ), + ); + final second = await client.run( + const DirectSingleAgentRunRequest( + sessionId: 'session-cwd-change', + provider: SingleAgentProvider.opencode, + prompt: 'second turn', + model: 'gpt-4.1', + workingDirectory: '/tmp/b', + gatewayToken: '', + ), + ); + + expect(first.success, isTrue, reason: first.errorMessage); + expect(second.success, isTrue, reason: second.errorMessage); + expect(second.resolvedWorkingDirectory, '/tmp/b'); + expect( + server.methods.where((method) => method == 'thread/start').length, + 2, + ); + }, + ); + test('sends selected skills as structured app-server inputs', () async { final server = await _FakeAppServer.start(); addTearDown(server.close); @@ -202,12 +244,12 @@ void main() { ), ); - expect(result.success, isTrue); - expect(result.output, 'hello world from app server'); - expect(result.resolvedModel, 'codex-sonnet'); - expect(result.resolvedWorkingDirectory, '/tmp'); - expect(result.resolvedWorkspaceRefKind, WorkspaceRefKind.localPath); - }, + expect(result.success, isTrue); + expect(result.output, 'hello world from app server'); + expect(result.resolvedModel, 'codex-sonnet'); + expect(result.resolvedWorkingDirectory, '/tmp'); + expect(result.resolvedWorkspaceRefKind, WorkspaceRefKind.localPath); + }, ); test('captures the resolved thread path returned by app-server', () async { @@ -235,8 +277,7 @@ void main() { expect(result.success, isTrue); expect(result.resolvedWorkingDirectory, '/tmp/app-server-thread'); expect(result.resolvedWorkspaceRefKind, WorkspaceRefKind.localPath); - }, - ); + }); test( 'probes OpenCode REST endpoint and reports provider support', @@ -289,6 +330,44 @@ void main() { expect(server.lastPromptText, 'hello opencode'); }); + test( + 'creates a new REST session when working directory changes for a session', + () async { + final server = await _FakeOpenCodeRestServer.start(); + addTearDown(server.close); + + final client = DirectSingleAgentAppServerClient( + endpointResolver: (_) => server.baseHttpUri, + ); + addTearDown(client.dispose); + + final first = await client.run( + const DirectSingleAgentRunRequest( + sessionId: 'session-opencode-cwd-change', + provider: SingleAgentProvider.opencode, + prompt: 'first', + model: '', + workingDirectory: '/tmp/a', + gatewayToken: '', + ), + ); + final second = await client.run( + const DirectSingleAgentRunRequest( + sessionId: 'session-opencode-cwd-change', + provider: SingleAgentProvider.opencode, + prompt: 'second', + model: '', + workingDirectory: '/tmp/b', + gatewayToken: '', + ), + ); + + expect(first.success, isTrue, reason: first.errorMessage); + expect(second.success, isTrue, reason: second.errorMessage); + expect(server.createdSessionCount, 2); + }, + ); + test( 'fails OpenCode REST turns that complete without assistant content', () async {