[03-Developer] #260 round 2: AC-9 + AC-10 보강

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
This commit is contained in:
2026-06-15 10:54:53 +09:00
parent b1bed4d5ca
commit b9f5674f51
3 changed files with 179 additions and 1 deletions

View File

@@ -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<ChatSessionState> {
);
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<String, dynamic>.
final capped = jsonDecode(encodeToolResult(result)) as Map<String, dynamic>;
pendingToolResult = capped;
final capturedName = pendingToolName;
final capturedResult = pendingToolResult;
nextStream = () => _session!.sendToolResult(

View File

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

View File

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