fix(single-agent): honor thread cwd for debug sessions
This commit is contained in:
parent
2a65d1743a
commit
5e15e7f6ad
@ -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() {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user