fix(single-agent): honor thread cwd for debug sessions

This commit is contained in:
Haitao Pan 2026-03-28 09:40:07 +08:00
parent 2a65d1743a
commit 5e15e7f6ad
4 changed files with 195 additions and 30 deletions

View File

@ -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() {

View File

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

View File

@ -388,6 +388,7 @@ class _DirectSingleAgentWebSocketTransport {
final Map<String, _DirectAppServerConnection> _activeConnections =
<String, _DirectAppServerConnection>{};
final Map<String, String> _threadIds = <String, String>{};
final Map<String, String> _threadWorkingDirectories = <String, String>{};
final Set<String> _abortedSessions = <String>{};
Future<void> 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: <String, dynamic>{
'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: <String, dynamic>{
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<String, String> _restSessionIds = <String, String>{};
final Map<String, String> _restSessionWorkingDirectories = <String, String>{};
final Set<String> _abortedSessions = <String>{};
Future<void> 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: <String, String>{'directory': workingDirectory},
queryParameters: <String, String>{
'directory': normalizedWorkingDirectory,
},
),
body: <String, dynamic>{'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;
}

View File

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