feat: 支持 agent 运行取消功能
This commit is contained in:
@@ -50,6 +50,8 @@ class AgUiService {
|
||||
Completer<void>? _activeSseDoneCompleter;
|
||||
|
||||
String? _threadId;
|
||||
String? _activeThreadIdForRun;
|
||||
String? _activeRunId;
|
||||
bool _hasMoreHistory = false;
|
||||
|
||||
AgUiService({EventCallback? onEvent, required IApiClient apiClient})
|
||||
@@ -83,11 +85,20 @@ class AgUiService {
|
||||
throw StateError('Missing runId in /agent/runs response');
|
||||
}
|
||||
_threadId = threadId;
|
||||
await _streamEventsFromApi(
|
||||
threadId,
|
||||
expectedRunId: runId,
|
||||
streamToken: streamToken,
|
||||
);
|
||||
_activeThreadIdForRun = threadId;
|
||||
_activeRunId = runId;
|
||||
try {
|
||||
await _streamEventsFromApi(
|
||||
threadId,
|
||||
expectedRunId: runId,
|
||||
streamToken: streamToken,
|
||||
);
|
||||
} finally {
|
||||
if (_activeThreadIdForRun == threadId && _activeRunId == runId) {
|
||||
_activeThreadIdForRun = null;
|
||||
_activeRunId = null;
|
||||
}
|
||||
}
|
||||
return SendMessageResult(
|
||||
uploadedAttachments: runInputPayload.uploadedAttachments,
|
||||
);
|
||||
@@ -151,6 +162,19 @@ class AgUiService {
|
||||
}
|
||||
|
||||
Future<void> cancelCurrentRun() async {
|
||||
final activeThreadId = _activeThreadIdForRun;
|
||||
final activeRunId = _activeRunId;
|
||||
if (activeThreadId != null && activeRunId != null) {
|
||||
final encodedRunId = Uri.encodeQueryComponent(activeRunId);
|
||||
await _apiClient.post<Map<String, dynamic>>(
|
||||
'/api/v1/agent/runs/$activeThreadId/cancel?runId=$encodedRunId',
|
||||
);
|
||||
_activeThreadIdForRun = null;
|
||||
_activeRunId = null;
|
||||
_activeStreamToken += 1;
|
||||
await _cancelActiveSseSubscription();
|
||||
return;
|
||||
}
|
||||
_activeStreamToken += 1;
|
||||
await _cancelActiveSseSubscription();
|
||||
}
|
||||
|
||||
@@ -123,10 +123,16 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
);
|
||||
case AgUiEventType.runError:
|
||||
final errorEvent = event as RunErrorEvent;
|
||||
final isCanceledByUser = errorEvent.code == 'RUN_CANCELED';
|
||||
emit(
|
||||
_resetRunState(
|
||||
error: errorEvent.message,
|
||||
).copyWith(items: _markActiveToolCallsFailed(state.items)),
|
||||
error: isCanceledByUser ? null : errorEvent.message,
|
||||
).copyWith(
|
||||
items: _markActiveToolCallsFailed(
|
||||
state.items,
|
||||
reason: isCanceledByUser ? '本次运行已取消' : '本次运行已失败',
|
||||
),
|
||||
),
|
||||
);
|
||||
case AgUiEventType.stepStarted:
|
||||
_handleStepStarted(event as StepStartedEvent);
|
||||
@@ -286,7 +292,10 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
return items.where((item) => item is! ToolCallItem).toList();
|
||||
}
|
||||
|
||||
List<ChatListItem> _markActiveToolCallsFailed(List<ChatListItem> items) {
|
||||
List<ChatListItem> _markActiveToolCallsFailed(
|
||||
List<ChatListItem> items, {
|
||||
required String reason,
|
||||
}) {
|
||||
return items.map((item) {
|
||||
if (item is! ToolCallItem) {
|
||||
return item;
|
||||
@@ -297,10 +306,7 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
if (item.status == ToolCallStatus.completed) {
|
||||
return item;
|
||||
}
|
||||
return item.copyWith(
|
||||
status: ToolCallStatus.error,
|
||||
errorMessage: '本次运行已失败',
|
||||
);
|
||||
return item.copyWith(status: ToolCallStatus.error, errorMessage: reason);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ extension _HomeScreenInteractions on _HomeScreenState {
|
||||
return;
|
||||
}
|
||||
if (canceled) {
|
||||
Toast.show(context, '已停止等待回复', type: ToastType.info);
|
||||
Toast.show(context, '已请求停止', type: ToastType.info);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class _FakeApiClient implements IApiClient {
|
||||
final List<String> sseLines;
|
||||
final Stream<String> Function()? sseLineStreamFactory;
|
||||
final String Function()? runIdFactory;
|
||||
final List<String> postPaths = <String>[];
|
||||
|
||||
@override
|
||||
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
|
||||
@@ -51,6 +52,19 @@ class _FakeApiClient implements IApiClient {
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
|
||||
postPaths.add(path);
|
||||
if (path.contains('/cancel?runId=')) {
|
||||
final payload = <String, dynamic>{
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-new',
|
||||
'accepted': true,
|
||||
};
|
||||
return Response<T>(
|
||||
requestOptions: RequestOptions(path: path),
|
||||
data: payload as T,
|
||||
statusCode: 202,
|
||||
);
|
||||
}
|
||||
final runIdFactory = this.runIdFactory;
|
||||
final payload = <String, dynamic>{
|
||||
'taskId': 'task-1',
|
||||
@@ -192,6 +206,39 @@ void main() {
|
||||
await streamController.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'cancelCurrentRun calls backend cancel endpoint for active run',
|
||||
() async {
|
||||
final streamController = StreamController<String>();
|
||||
final fakeApi = _FakeApiClient(
|
||||
sseLines: const <String>[],
|
||||
sseLineStreamFactory: () => streamController.stream,
|
||||
);
|
||||
final service = AgUiService(apiClient: fakeApi);
|
||||
|
||||
final sendFuture = service.sendMessage('hello');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (final line in _buildSseEvent(
|
||||
id: '51',
|
||||
type: AgUiEventTypeWire.runStarted,
|
||||
payload:
|
||||
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
|
||||
)) {
|
||||
streamController.add(line);
|
||||
}
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await service.cancelCurrentRun();
|
||||
await sendFuture;
|
||||
|
||||
expect(
|
||||
fakeApi.postPaths,
|
||||
contains('/api/v1/agent/runs/thread-1/cancel?runId=run-new'),
|
||||
);
|
||||
await streamController.close();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'new sendMessage cancels previous SSE subscription explicitly',
|
||||
() async {
|
||||
|
||||
@@ -206,6 +206,28 @@ void main() {
|
||||
expect(bloc.state.error, 'runtime execution failed');
|
||||
});
|
||||
|
||||
test('run canceled error clears error and marks tool as canceled', () {
|
||||
service.emitEvent(
|
||||
ToolCallStartEvent(
|
||||
toolCallId: 'tool-cancel',
|
||||
toolCallName: 'ocr_image',
|
||||
),
|
||||
);
|
||||
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-cancel'));
|
||||
|
||||
service.emitEvent(
|
||||
RunErrorEvent(message: 'run canceled by user', code: 'RUN_CANCELED'),
|
||||
);
|
||||
|
||||
final toolItem = bloc.state.items.whereType<ToolCallItem>().single;
|
||||
expect(toolItem.status, ToolCallStatus.error);
|
||||
expect(toolItem.errorMessage, '本次运行已取消');
|
||||
expect(bloc.state.error, isNull);
|
||||
expect(bloc.state.isWaitingFirstToken, isFalse);
|
||||
expect(bloc.state.isStreaming, isFalse);
|
||||
expect(bloc.state.isCancelling, isFalse);
|
||||
});
|
||||
|
||||
test('text event with ui schema is rendered into chat items', () {
|
||||
service.emitEvent(RunStartedEvent(threadId: 'thread-1', runId: 'run-1'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user