fix: stabilize chat run lifecycle rendering

This commit is contained in:
qzl
2026-03-17 15:58:29 +08:00
parent 3bf7640000
commit cf56b358ad
5 changed files with 673 additions and 75 deletions
@@ -0,0 +1,152 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
class _FakeApiClient implements IApiClient {
_FakeApiClient({required this.sseLines});
final List<String> sseLines;
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) {
throw UnimplementedError();
}
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
}) async {
return Stream<String>.fromIterable(sseLines);
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) async {
final payload = <String, dynamic>{
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-new',
'created': true,
};
return Response<T>(
requestOptions: RequestOptions(path: path),
data: payload as T,
statusCode: 202,
);
}
}
List<String> _buildSseEvent({
required String id,
required String type,
required String payload,
}) {
return <String>['id: $id', 'event: $type', 'data: $payload', ''];
}
void main() {
test(
'sendMessage ignores stale run events and waits for expected run',
() async {
final oldRunLines = _buildSseEvent(
id: '1',
type: AgUiEventTypeWire.runStarted,
payload:
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-old"}',
);
final oldFinishedLines = _buildSseEvent(
id: '2',
type: AgUiEventTypeWire.runFinished,
payload:
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-old"}',
);
final newRunLines = _buildSseEvent(
id: '3',
type: AgUiEventTypeWire.runStarted,
payload:
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
);
final newFinishedLines = _buildSseEvent(
id: '4',
type: AgUiEventTypeWire.runFinished,
payload:
'{"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-new"}',
);
final service = AgUiService(
apiClient: _FakeApiClient(
sseLines: <String>[
...oldRunLines,
...oldFinishedLines,
...newRunLines,
...newFinishedLines,
],
),
);
final events = <AgUiEvent>[];
service.onEvent = events.add;
await service.sendMessage('hello');
expect(events, hasLength(2));
expect(events.first, isA<RunStartedEvent>());
expect((events.first as RunStartedEvent).runId, 'run-new');
expect(events.last, isA<RunFinishedEvent>());
expect((events.last as RunFinishedEvent).runId, 'run-new');
},
);
test(
'sendMessage accepts in-run terminal event without runId after binding',
() async {
final newRunLines = _buildSseEvent(
id: '11',
type: AgUiEventTypeWire.runStarted,
payload:
'{"type":"RUN_STARTED","threadId":"thread-1","runId":"run-new"}',
);
final noRunIdTextLines = _buildSseEvent(
id: '12',
type: AgUiEventTypeWire.textMessageEnd,
payload:
'{"type":"TEXT_MESSAGE_END","threadId":"thread-1","messageId":"m1","answer":"ok","role":"assistant","status":"success"}',
);
final noRunIdFinishedLines = _buildSseEvent(
id: '13',
type: AgUiEventTypeWire.runFinished,
payload: '{"type":"RUN_FINISHED","threadId":"thread-1"}',
);
final service = AgUiService(
apiClient: _FakeApiClient(
sseLines: <String>[
...newRunLines,
...noRunIdTextLines,
...noRunIdFinishedLines,
],
),
);
final events = <AgUiEvent>[];
service.onEvent = events.add;
await service.sendMessage('hello');
expect(events, hasLength(3));
expect(events[0], isA<RunStartedEvent>());
expect(events[1], isA<TextMessageEndEvent>());
expect(events[2], isA<RunFinishedEvent>());
},
);
}
@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
class _NoopApiClient implements IApiClient {
@override
Future<Response<T>> delete<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> get<T>(String path, {Options? options}) {
throw UnimplementedError();
}
@override
Future<Stream<String>> getSseLines(
String path, {
Map<String, String>? headers,
}) {
throw UnimplementedError();
}
@override
Future<Response<T>> patch<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
@override
Future<Response<T>> post<T>(String path, {data, Options? options}) {
throw UnimplementedError();
}
}
class _FakeAgUiService extends AgUiService {
_FakeAgUiService() : super(apiClient: _NoopApiClient());
Completer<SendMessageResult>? pendingResult;
Object? nextError;
@override
Future<SendMessageResult> sendMessage(
String content, {
List<XFile>? images,
}) async {
final error = nextError;
if (error != null) {
nextError = null;
throw error;
}
final pending = pendingResult;
if (pending != null) {
return pending.future;
}
return const SendMessageResult(uploadedAttachments: []);
}
void emitEvent(AgUiEvent event) {
onEvent(event);
}
}
void main() {
group('ChatBloc attachment sync', () {
late _FakeAgUiService service;
late ChatBloc bloc;
setUp(() {
service = _FakeAgUiService();
bloc = ChatBloc(service: service, apiClient: _NoopApiClient());
});
tearDown(() async {
await bloc.close();
});
test('optimistic local image is replaced with uploaded url', () async {
final completer = Completer<SendMessageResult>();
service.pendingResult = completer;
final sendFuture = bloc.sendMessage(
'hello',
images: [
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
],
);
await Future<void>.delayed(Duration.zero);
final optimistic = bloc.state.items.last as TextMessageItem;
expect(optimistic.attachments, hasLength(1));
expect(optimistic.attachments.first['path'], '/tmp/local.jpg');
expect(optimistic.attachments.first['uploading'], isTrue);
completer.complete(
const SendMessageResult(
uploadedAttachments: [
UploadedAttachment(
localPath: '/tmp/local.jpg',
url: 'https://cdn.example.com/a.jpg',
mimeType: 'image/jpeg',
),
],
),
);
await sendFuture;
final synced = bloc.state.items.last as TextMessageItem;
expect(synced.attachments.first['url'], 'https://cdn.example.com/a.jpg');
expect(synced.attachments.first['uploading'], isFalse);
});
test(
'upload failure clears uploading state to avoid endless spinner',
() async {
service.nextError = StateError('upload failed');
await bloc.sendMessage(
'hello',
images: [
XFile('/tmp/local.jpg', name: 'local.jpg', mimeType: 'image/jpeg'),
],
);
final failed = bloc.state.items.last as TextMessageItem;
expect(failed.attachments.first['uploading'], isFalse);
expect(bloc.state.error, contains('upload failed'));
},
);
test('tool call stays visible until assistant final output', () {
service.emitEvent(
ToolCallStartEvent(toolCallId: 'tool-1', toolCallName: 'ocr_image'),
);
var toolItem = bloc.state.items.last as ToolCallItem;
expect(toolItem.status, ToolCallStatus.pending);
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-1'));
toolItem = bloc.state.items.last as ToolCallItem;
expect(toolItem.status, ToolCallStatus.executing);
service.emitEvent(
ToolCallResultEvent(
messageId: 'tool-msg-1',
toolCallId: 'tool-1',
toolName: 'ocr_image',
resultSummary: 'done',
status: 'success',
uiSchema: null,
),
);
toolItem = bloc.state.items.last as ToolCallItem;
expect(toolItem.status, ToolCallStatus.completed);
service.emitEvent(
TextMessageEndEvent(
messageId: 'assistant-1',
answer: '识别完成',
role: 'assistant',
status: 'success',
uiSchema: null,
),
);
expect(bloc.state.items.whereType<ToolCallItem>(), isEmpty);
expect(bloc.state.items.whereType<TextMessageItem>().length, 1);
});
test('run error keeps tool card and marks it failed', () {
service.emitEvent(
ToolCallStartEvent(toolCallId: 'tool-err', toolCallName: 'ocr_image'),
);
service.emitEvent(ToolCallEndEvent(toolCallId: 'tool-err'));
service.emitEvent(RunErrorEvent(message: 'runtime execution failed'));
final toolItem = bloc.state.items.whereType<ToolCallItem>().single;
expect(toolItem.status, ToolCallStatus.error);
expect(toolItem.errorMessage, '本次运行已失败');
expect(bloc.state.error, 'runtime execution failed');
});
});
}