fix: preserve acp interrupted task results

This commit is contained in:
Haitao Pan 2026-05-08 14:19:26 +08:00
parent 17dc0281a2
commit e156ecf808
6 changed files with 339 additions and 17 deletions

View File

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

View File

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

View File

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

View 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();
}

View File

@ -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),
),
),
);
}

View File

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