fix: preserve acp interrupted task results
This commit is contained in:
parent
17dc0281a2
commit
e156ecf808
@ -228,17 +228,9 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
}) {
|
||||
final raw = error.toString().trim();
|
||||
final lowered = raw.toLowerCase();
|
||||
final detailCode = error is GatewayAcpException
|
||||
? error.detailCode?.trim().toUpperCase()
|
||||
: null;
|
||||
final primaryCode = error is GatewayAcpException
|
||||
? error.code?.trim().toUpperCase()
|
||||
: null;
|
||||
final acpHttpConnectionClosed =
|
||||
primaryCode == 'ACP_HTTP_CONNECTION_CLOSED' ||
|
||||
detailCode == 'ACP_HTTP_CONNECTION_CLOSED' ||
|
||||
raw.contains('ACP_HTTP_CONNECTION_CLOSED');
|
||||
if (acpHttpConnectionClosed) {
|
||||
final detailCode = gatewayExecutionDetailCodeInternal(error);
|
||||
final primaryCode = gatewayExecutionPrimaryCodeInternal(error);
|
||||
if (isAcpHttpConnectionClosedErrorInternal(error)) {
|
||||
return appText(
|
||||
'Bridge 响应读取中断;当前对话已保留,下一次发送会继续同一会话。错误码:ACP_HTTP_CONNECTION_CLOSED',
|
||||
'Bridge response was interrupted; this conversation was kept, and the next send will continue the same session. Error code: ACP_HTTP_CONNECTION_CLOSED',
|
||||
@ -290,6 +282,27 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return raw;
|
||||
}
|
||||
|
||||
String? gatewayExecutionPrimaryCodeInternal(Object error) {
|
||||
return error is GatewayAcpException
|
||||
? error.code?.trim().toUpperCase()
|
||||
: null;
|
||||
}
|
||||
|
||||
String? gatewayExecutionDetailCodeInternal(Object error) {
|
||||
return error is GatewayAcpException
|
||||
? error.detailCode?.trim().toUpperCase()
|
||||
: null;
|
||||
}
|
||||
|
||||
bool isAcpHttpConnectionClosedErrorInternal(Object error) {
|
||||
final raw = error.toString().trim();
|
||||
final primaryCode = gatewayExecutionPrimaryCodeInternal(error);
|
||||
final detailCode = gatewayExecutionDetailCodeInternal(error);
|
||||
return primaryCode == 'ACP_HTTP_CONNECTION_CLOSED' ||
|
||||
detailCode == 'ACP_HTTP_CONNECTION_CLOSED' ||
|
||||
raw.contains('ACP_HTTP_CONNECTION_CLOSED');
|
||||
}
|
||||
|
||||
String formatAiGatewayHttpErrorInternal(int statusCode, String detail) {
|
||||
final base = switch (statusCode) {
|
||||
400 => appText(
|
||||
@ -623,6 +636,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
final existingThread = requireTaskThreadForSessionInternal(
|
||||
normalizedSessionKey,
|
||||
);
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
lastArtifactSyncAtMs: syncedAtMs,
|
||||
lastArtifactSyncStatus: 'syncing',
|
||||
updatedAtMs: syncedAtMs,
|
||||
);
|
||||
recomputeTasksInternal();
|
||||
notifyIfActiveInternal();
|
||||
if (existingThread.workspaceBinding.workspaceKind !=
|
||||
WorkspaceKind.localFs) {
|
||||
upsertTaskThreadInternal(
|
||||
|
||||
@ -352,6 +352,13 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
persistInThreadContext: true,
|
||||
);
|
||||
aiGatewayPendingSessionKeysInternal.add(sessionKey);
|
||||
upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lifecycleStatus: 'running',
|
||||
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastResultCode: 'running',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
recomputeTasksInternal();
|
||||
notifyIfActiveInternal();
|
||||
try {
|
||||
@ -452,11 +459,17 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
);
|
||||
} catch (error) {
|
||||
clearAiGatewayStreamingTextInternal(sessionKey);
|
||||
final connectionClosed = isAcpHttpConnectionClosedErrorInternal(
|
||||
error,
|
||||
);
|
||||
upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lifecycleStatus: 'ready',
|
||||
lifecycleStatus: connectionClosed ? 'interrupted' : 'ready',
|
||||
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastResultCode: 'error',
|
||||
lastResultCode: connectionClosed
|
||||
? 'ACP_HTTP_CONNECTION_CLOSED'
|
||||
: 'error',
|
||||
lastArtifactSyncStatus: connectionClosed ? 'interrupted' : null,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
appendLocalSessionMessageInternal(
|
||||
|
||||
@ -22,6 +22,7 @@ import '../../theme/app_palette.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../widgets/assistant_focus_panel.dart';
|
||||
import '../../widgets/assistant_artifact_sidebar.dart';
|
||||
import '../../widgets/assistant_task_progress_bar.dart';
|
||||
import '../../widgets/desktop_workspace_scaffold.dart';
|
||||
import '../../widgets/pane_resize_handle.dart';
|
||||
import '../../widgets/surface_card.dart';
|
||||
@ -111,6 +112,17 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
(defaultComposerHeight + workspaceLowerPaneHeightAdjustmentInternal)
|
||||
.clamp(composerHeightLowerBound, composerHeightUpperBound)
|
||||
.toDouble();
|
||||
final thread = controller.taskThreadForSessionInternal(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
final progressState = assistantTaskProgressState(
|
||||
pending: controller.assistantSessionHasPendingRun(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
lifecycleStatus: thread?.lifecycleState.status ?? '',
|
||||
lastResultCode: thread?.lifecycleState.lastResultCode ?? '',
|
||||
artifactSyncStatus: thread?.lastArtifactSyncStatus ?? '',
|
||||
);
|
||||
|
||||
return SurfaceCard(
|
||||
borderRadius: 0,
|
||||
@ -149,6 +161,7 @@ extension AssistantPageStateClosureInternal on AssistantPageStateInternal {
|
||||
),
|
||||
),
|
||||
),
|
||||
AssistantTaskProgressBar(state: progressState),
|
||||
ColoredBox(
|
||||
color: palette.canvas,
|
||||
child: SizedBox(
|
||||
|
||||
118
lib/widgets/assistant_task_progress_bar.dart
Normal file
118
lib/widgets/assistant_task_progress_bar.dart
Normal file
@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../i18n/app_language.dart';
|
||||
|
||||
enum AssistantTaskProgressPhase { idle, running, syncingArtifacts, interrupted }
|
||||
|
||||
class AssistantTaskProgressState {
|
||||
const AssistantTaskProgressState({
|
||||
required this.phase,
|
||||
required this.label,
|
||||
this.value,
|
||||
});
|
||||
|
||||
const AssistantTaskProgressState.idle()
|
||||
: phase = AssistantTaskProgressPhase.idle,
|
||||
label = '',
|
||||
value = null;
|
||||
|
||||
final AssistantTaskProgressPhase phase;
|
||||
final String label;
|
||||
final double? value;
|
||||
|
||||
bool get visible => phase != AssistantTaskProgressPhase.idle;
|
||||
bool get interrupted => phase == AssistantTaskProgressPhase.interrupted;
|
||||
}
|
||||
|
||||
class AssistantTaskProgressBar extends StatelessWidget {
|
||||
const AssistantTaskProgressBar({super.key, required this.state});
|
||||
|
||||
final AssistantTaskProgressState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!state.visible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final theme = Theme.of(context);
|
||||
final color = state.interrupted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.primary;
|
||||
return Container(
|
||||
key: const Key('assistant-task-progress-bar'),
|
||||
constraints: const BoxConstraints(minHeight: 34),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: state.interrupted
|
||||
? theme.colorScheme.errorContainer.withValues(alpha: 0.18)
|
||||
: theme.colorScheme.primaryContainer.withValues(alpha: 0.18),
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.dividerColor.withValues(alpha: 0.42)),
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.42)),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
state.label,
|
||||
key: const Key('assistant-task-progress-label'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
LinearProgressIndicator(
|
||||
key: const Key('assistant-task-progress-indicator'),
|
||||
value: state.value,
|
||||
minHeight: 3,
|
||||
color: color,
|
||||
backgroundColor: color.withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AssistantTaskProgressState assistantTaskProgressState({
|
||||
required bool pending,
|
||||
required String lifecycleStatus,
|
||||
required String lastResultCode,
|
||||
required String artifactSyncStatus,
|
||||
}) {
|
||||
final syncStatus = artifactSyncStatus.trim().toLowerCase();
|
||||
if (pending && syncStatus == 'syncing') {
|
||||
return AssistantTaskProgressState(
|
||||
phase: AssistantTaskProgressPhase.syncingArtifacts,
|
||||
label: appText('正在同步生成文件...', 'Syncing generated files...'),
|
||||
value: 0.82,
|
||||
);
|
||||
}
|
||||
if (pending) {
|
||||
return AssistantTaskProgressState(
|
||||
phase: AssistantTaskProgressPhase.running,
|
||||
label: appText('任务运行中...', 'Task running...'),
|
||||
);
|
||||
}
|
||||
final status = lifecycleStatus.trim().toLowerCase();
|
||||
final result = lastResultCode.trim().toUpperCase();
|
||||
if (status == 'interrupted' ||
|
||||
syncStatus == 'interrupted' ||
|
||||
result == 'ACP_HTTP_CONNECTION_CLOSED') {
|
||||
return AssistantTaskProgressState(
|
||||
phase: AssistantTaskProgressPhase.interrupted,
|
||||
label: appText(
|
||||
'Bridge 响应中断,等待下一次发送续写同一会话。',
|
||||
'Bridge response interrupted; the next send will continue this session.',
|
||||
),
|
||||
value: 0.48,
|
||||
);
|
||||
}
|
||||
return const AssistantTaskProgressState.idle();
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
import 'package:xworkmate/widgets/assistant_task_progress_bar.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows running progress while a task is pending', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
assistantTaskProgressState(
|
||||
pending: true,
|
||||
lifecycleStatus: 'running',
|
||||
lastResultCode: 'running',
|
||||
artifactSyncStatus: '',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-task-progress-bar')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('任务运行中...'), findsOneWidget);
|
||||
final indicator = tester.widget<LinearProgressIndicator>(
|
||||
find.byKey(const Key('assistant-task-progress-indicator')),
|
||||
);
|
||||
expect(indicator.value, isNull);
|
||||
});
|
||||
|
||||
testWidgets('shows artifact sync progress while files are syncing', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
assistantTaskProgressState(
|
||||
pending: true,
|
||||
lifecycleStatus: 'running',
|
||||
lastResultCode: 'running',
|
||||
artifactSyncStatus: 'syncing',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('正在同步生成文件...'), findsOneWidget);
|
||||
final indicator = tester.widget<LinearProgressIndicator>(
|
||||
find.byKey(const Key('assistant-task-progress-indicator')),
|
||||
);
|
||||
expect(indicator.value, 0.82);
|
||||
});
|
||||
|
||||
testWidgets('shows interrupted state after ACP connection closes', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
assistantTaskProgressState(
|
||||
pending: false,
|
||||
lifecycleStatus: 'interrupted',
|
||||
lastResultCode: 'ACP_HTTP_CONNECTION_CLOSED',
|
||||
artifactSyncStatus: 'interrupted',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Bridge 响应中断,等待下一次发送续写同一会话。'), findsOneWidget);
|
||||
final indicator = tester.widget<LinearProgressIndicator>(
|
||||
find.byKey(const Key('assistant-task-progress-indicator')),
|
||||
);
|
||||
expect(indicator.value, 0.48);
|
||||
});
|
||||
|
||||
testWidgets('hides idle progress state', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(const AssistantTaskProgressState.idle()),
|
||||
);
|
||||
|
||||
expect(find.byKey(const Key('assistant-task-progress-bar')), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildTestApp(AssistantTaskProgressState state) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Material(
|
||||
child: SizedBox(
|
||||
width: 420,
|
||||
child: AssistantTaskProgressBar(state: state),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -635,6 +635,14 @@ void main() {
|
||||
test(
|
||||
'sendChatMessage continues the same session after ACP HTTP connection close',
|
||||
() async {
|
||||
final localWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-acp-interrupt-artifacts-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await localWorkspace.exists()) {
|
||||
await localWorkspace.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
final fakeGoTaskService = _RecordingGoTaskServiceClient()
|
||||
..updatesBeforeNextOutcome.add(
|
||||
const GoTaskServiceUpdate(
|
||||
@ -657,11 +665,11 @@ void main() {
|
||||
),
|
||||
)
|
||||
..outcomes.add(
|
||||
const GoTaskServiceResult(
|
||||
GoTaskServiceResult(
|
||||
success: true,
|
||||
message: 'continued response',
|
||||
message: '全部 6 个文件已生成 ✅',
|
||||
turnId: 'turn-2',
|
||||
raw: <String, dynamic>{},
|
||||
raw: <String, dynamic>{'artifacts': _generatedArtifactPayloads()},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
@ -669,6 +677,7 @@ void main() {
|
||||
);
|
||||
final controller = _connectedController(fakeGoTaskService);
|
||||
addTearDown(controller.dispose);
|
||||
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
|
||||
|
||||
await controller.sessionsController.switchSession('session-1');
|
||||
|
||||
@ -676,6 +685,13 @@ void main() {
|
||||
|
||||
expect(fakeGoTaskService.requests, hasLength(1));
|
||||
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
|
||||
expect(
|
||||
controller
|
||||
.taskThreadForSessionInternal('session-1')
|
||||
?.lifecycleState
|
||||
.status,
|
||||
'interrupted',
|
||||
);
|
||||
expect(
|
||||
controller.chatMessages.last.text,
|
||||
'Bridge 响应读取中断;当前对话已保留,下一次发送会继续同一会话。错误码:ACP_HTTP_CONNECTION_CLOSED',
|
||||
@ -689,7 +705,22 @@ void main() {
|
||||
|
||||
expect(fakeGoTaskService.requests, hasLength(2));
|
||||
expect(fakeGoTaskService.requests.last.resumeSession, isTrue);
|
||||
expect(controller.chatMessages.last.text, 'continued response');
|
||||
expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅');
|
||||
final thread = controller.taskThreadForSessionInternal('session-1');
|
||||
expect(thread?.lifecycleState.status, 'ready');
|
||||
expect(thread?.lastArtifactSyncStatus, 'synced');
|
||||
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
|
||||
final workspacePath = controller.assistantWorkspacePathForSession(
|
||||
'session-1',
|
||||
);
|
||||
for (final artifact in _generatedArtifactPayloads()) {
|
||||
final relativePath = artifact['relativePath']! as String;
|
||||
final content = artifact['content']! as String;
|
||||
expect(
|
||||
await File('$workspacePath/$relativePath').readAsString(),
|
||||
content,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -847,6 +878,41 @@ class _CapabilityServerCapture {
|
||||
Future<void> close() => _server.close(force: true);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _generatedArtifactPayloads() {
|
||||
return <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'relativePath': '网络与协议专题-图片生成提示词.md',
|
||||
'content': 'prompt content',
|
||||
'contentType': 'text/markdown',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'relativePath': '小红书风格文案.md',
|
||||
'content': 'xiaohongshu copy',
|
||||
'contentType': 'text/markdown',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'relativePath': 'X文案.md',
|
||||
'content': 'x copy',
|
||||
'contentType': 'text/markdown',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'relativePath': '领英文案.md',
|
||||
'content': 'linkedin copy',
|
||||
'contentType': 'text/markdown',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'relativePath': '云原生网络与协议专题.pptx',
|
||||
'content': 'pptx bytes',
|
||||
'contentType': 'application/octet-stream',
|
||||
},
|
||||
<String, dynamic>{
|
||||
'relativePath': 'PptxGenJS_脚本.js',
|
||||
'content': 'console.log("pptx");',
|
||||
'contentType': 'text/javascript',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
AppController _connectedController(GoTaskServiceClient client) {
|
||||
return AppController(
|
||||
goTaskServiceClient: client,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user