From b9f5674f519859a86e62e26978afef865ed8dc63 Mon Sep 17 00:00:00 2001 From: joungmin Date: Mon, 15 Jun 2026 10:54:53 +0900 Subject: [PATCH] =?UTF-8?q?[03-Developer]=20#260=20round=202:=20AC-9=20+?= =?UTF-8?q?=20AC-10=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA round 1 환송 노트 (#260 카테고리 63 환송) 의 두 결함 수정. AC-9 — tool result 2KB 가드 runtime 연결 - chat_providers.dart userTurn: result.toJson() → encodeToolResult 통과 후 jsonDecode 한 Map 을 LlmChatSession.sendToolResult 로 전달. - encodeToolResult 가 더 이상 dead code 가 아니다. ADR-0005 / OQ-2 의 2KB hard cap 이 실 경로에서 적용됨. - 회귀: chat_session_controller_test.dart 신규 'AC-9 대용량 → cap' 케이스 — 인위 huge_dump tool 로 _truncated:true + _hint 검증. AC-10 — widget E2E 신규 - app/test/ui/chat_screen_test.dart 신규 (2 testWidgets): 1) add_habit tool call → ConfirmDialog '수행' → habits +1 + 모델 마무리. 2) ConfirmDialog '취소' → habits 무변화 + 'tool 취소됨' 라벨. - ProviderScope overrides: appDatabaseProvider / llmServiceProvider / bootstrapProvider / toolDepsProvider. 회귀 - 신규 3 (cap 1 + widget 2) → 151 → 154 passed (1 skip) - flutter analyze: clean Refs #260 --- app/lib/state/chat_providers.dart | 9 +- .../state/chat_session_controller_test.dart | 41 ++++++ app/test/ui/chat_screen_test.dart | 130 ++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 app/test/ui/chat_screen_test.dart diff --git a/app/lib/state/chat_providers.dart b/app/lib/state/chat_providers.dart index dc947a9..8a5c1e6 100644 --- a/app/lib/state/chat_providers.dart +++ b/app/lib/state/chat_providers.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -183,7 +185,12 @@ class ChatSessionController extends StateNotifier { ); pendingToolName = toolCall.name; - pendingToolResult = result.toJson(); + // ADR-0005 / OQ-2: hard-cap tool result at 2KB so LLM context window + // can't be blown by a runaway ToolOk payload. encodeToolResult applies + // truncate-with-hint when needed; jsonDecode round-trips back to a Map + // because the chat session API expects Map. + final capped = jsonDecode(encodeToolResult(result)) as Map; + pendingToolResult = capped; final capturedName = pendingToolName; final capturedResult = pendingToolResult; nextStream = () => _session!.sendToolResult( diff --git a/app/test/state/chat_session_controller_test.dart b/app/test/state/chat_session_controller_test.dart index e8f8124..78aca03 100644 --- a/app/test/state/chat_session_controller_test.dart +++ b/app/test/state/chat_session_controller_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/ai/tools/tool_definition.dart'; import 'package:life_helper/ai/tools/tool_dispatcher.dart'; import 'package:life_helper/ai/tools/tool_envelope.dart'; import 'package:life_helper/ai/tools/tool_registry.dart'; @@ -163,6 +164,46 @@ void main() { expect(h.controller.state.error, isNull); }); + testWidgets('대용량 tool result → 2KB cap 적용 (AC-9)', (tester) async { + // 인위적으로 큰 payload 를 돌려주는 fake tool 로 dispatcher 를 구성. + final hugePayload = { + 'items': List.generate(200, (i) => {'id': 'p_$i' * 5, 'text': 'x' * 20}), + }; + final hugeTool = ToolDefinition( + name: 'huge_dump', + description: 'test-only huge result', + parametersSchema: const { + 'type': 'object', + 'properties': {}, + 'required': [], + }, + handler: (_, _) async => ToolOk(hugePayload), + ); + final ctx2 = await bootstrapToolDeps(); + addTearDown(() => ctx2.db.close()); + final mock = MockLlmService(); + await mock.load(); + final controller = ChatSessionController( + llm: mock, + dispatcher: ToolDispatcher(registry: ToolRegistry([hugeTool])), + deps: ctx2.deps, + tools: [hugeTool], + ); + addTearDown(controller.dispose); + final ctx = await mountContext(tester); + + mock.enqueueChatEvents([const LlmFunctionCall('huge_dump', {})]); + mock.enqueueChatEvents([const LlmTextChunk('처리 완료.')]); + + await controller.userTurn('덤프', ctx); + + final submitted = mock.lastChat!.toolResults.first.$2; + expect(submitted['status'], 'ok'); + // encodeToolResult 가 cap 적용 → _truncated 마커 + _hint 메시지 포함. + expect((submitted['data'] as Map)['_truncated'], true); + expect((submitted['data'] as Map)['_hint'], contains('query_protocol')); + }); + testWidgets('tool result 가 다음 sendToolResult 로 전달', (tester) async { final h = await makeHarness(); addTearDown(() { diff --git a/app/test/ui/chat_screen_test.dart b/app/test/ui/chat_screen_test.dart new file mode 100644 index 0000000..fd23f8c --- /dev/null +++ b/app/test/ui/chat_screen_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:life_helper/data/ai/llm_service.dart'; +import 'package:life_helper/state/ai_providers.dart'; +import 'package:life_helper/state/chat_providers.dart'; +import 'package:life_helper/state/providers.dart'; +import 'package:life_helper/ui/screens/chat_screen.dart'; + +import '../ai/tools/_tool_test_helpers.dart'; + +/// Widget E2E for #260 (AC-10). Verifies the full chat → tool call → +/// ConfirmDialog → habit DB insert pipeline using a fully wired +/// ChatSessionController, with a `MockLlmService` standing in for Gemma. +void main() { + testWidgets( + 'add_habit tool call → ConfirmDialog 수행 → 활성 습관 +1', + (tester) async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + + final mock = MockLlmService(); + await mock.load(); + // Turn 1 (sendUser): LLM 이 add_habit 호출. + mock.enqueueChatEvents([ + const LlmFunctionCall('add_habit', { + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '아침에 햇빛 보기', + }), + ]); + // Turn 2 (sendToolResult): LLM 이 자연어로 마무리. + mock.enqueueChatEvents([ + const LlmTextChunk('아침 햇빛 습관을 추가했어요.'), + ]); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appDatabaseProvider.overrideWithValue(ctx.db), + llmServiceProvider.overrideWithValue(mock), + // bootstrapProvider 는 이미 ctx 에서 SeedImporter 가 끝났으므로 no-op. + bootstrapProvider.overrideWith((ref) async {}), + // toolDepsProvider 를 미리 resolve 된 형태로 주입. + toolDepsProvider.overrideWith((ref) async => ctx.deps), + ], + child: const MaterialApp(home: ChatScreen()), + ), + ); + await tester.pumpAndSettle(); + + // 활성 습관 0개에서 시작. + var habits = + await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId); + expect(habits, isEmpty); + + // 사용자 입력 → 전송. + await tester.enterText(find.byType(TextField), '아침 햇빛 추가해줘'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pump(); // userTurn 시작 + await tester.pump(const Duration(milliseconds: 50)); // mock stream + + // ConfirmDialog 가 떠야 한다. + expect(find.text('이 작업을 수행할까요?'), findsOneWidget); + expect(find.text('수행'), findsOneWidget); + expect(find.text('취소'), findsOneWidget); + + // 수행 탭. + await tester.tap(find.text('수행')); + await tester.pumpAndSettle(); + + // 활성 습관 1개. + habits = await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId); + expect(habits, hasLength(1)); + expect(habits.first.frameFramedText, '아침에 햇빛 보기'); + + // UI 에 모델 마무리 문구도 보인다. + expect(find.text('아침 햇빛 습관을 추가했어요.'), findsOneWidget); + }, + ); + + testWidgets( + 'ConfirmDialog 취소 → habit DB 변화 없음, ToolCancelled 메시지', + (tester) async { + final ctx = await bootstrapToolDeps(); + addTearDown(() => ctx.db.close()); + + final mock = MockLlmService(); + await mock.load(); + mock.enqueueChatEvents([ + const LlmFunctionCall('add_habit', { + 'protocol_id': 'morning_sunlight', + 'frame_level': 'L2', + 'framed_text': '아침에 햇빛 보기', + }), + ]); + mock.enqueueChatEvents([ + const LlmTextChunk('알겠어요, 추가하지 않았어요.'), + ]); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + appDatabaseProvider.overrideWithValue(ctx.db), + llmServiceProvider.overrideWithValue(mock), + bootstrapProvider.overrideWith((ref) async {}), + toolDepsProvider.overrideWith((ref) async => ctx.deps), + ], + child: const MaterialApp(home: ChatScreen()), + ), + ); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '추가'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(find.text('이 작업을 수행할까요?'), findsOneWidget); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + + final habits = + await ctx.deps.habitDao.activeHabitsForUser(ctx.deps.userId); + expect(habits, isEmpty); + // tool call 메시지 라벨이 '취소됨' 으로 표시. + expect(find.textContaining('취소됨'), findsOneWidget); + }, + ); +}