[03-Developer] #260 in-app tool calling (Gemma 4 multi-turn)
ADR-0005 in-process tool runtime — 6 tools (catalog 2 + tracker 2 + habit 2), ToolDispatcher with JSON-schema validation + modal ConfirmGate for destructive ops, multi-turn LlmChatSession abstraction wired to flutter_gemma 0.16.5 (ToolChoice.auto), ChatSessionController with MAX_TURNS=4 safety + 8-turn history hint, ChatScreen entry behind AI opt-in. R3/R7/R8 enforced inside handlers. 41 new tests (envelope, catalog/tracker/habit tools, dispatcher, controller loop) — 151 total passing. Refs #260
This commit is contained in:
130
app/test/ai/tools/habit_tools_test.dart
Normal file
130
app/test/ai/tools/habit_tools_test.dart
Normal file
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:life_helper/ai/tools/habit_tools.dart';
|
||||
import 'package:life_helper/ai/tools/tool_envelope.dart';
|
||||
import 'package:life_helper/core/constants.dart';
|
||||
import 'package:life_helper/core/time.dart';
|
||||
import 'package:life_helper/data/db/daos/habit_dao.dart';
|
||||
import 'package:life_helper/domain/models/habit.dart';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('add_habit', () {
|
||||
test('정상 build → ToolOk', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '아침에 햇빛 보기',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['type'], 'build');
|
||||
expect(data['frame_level'], 'L2');
|
||||
expect(data['habit_id'], isNotEmpty);
|
||||
});
|
||||
|
||||
test('정상 break → type=break', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'alcohol',
|
||||
'frame_level': 'L3',
|
||||
'framed_text': '맑은 정신을 즐긴다',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolOk>(), reason: '$r');
|
||||
expect((r as ToolOk).data['type'], 'break');
|
||||
});
|
||||
|
||||
test('L0 프레임 → validation 거부', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L0',
|
||||
'framed_text': '게으름 피우지 마',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('미존재 protocol_id → not_found', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'no_such',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '뭐든',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'not_found');
|
||||
});
|
||||
|
||||
test('R7 회피 키워드 → r7_avoidance', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
// 시드 framePatterns 에 "술 끊기" avoidance keyword 존재.
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'alcohol',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '술 끊기 해야지',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'r7_avoidance');
|
||||
});
|
||||
|
||||
test('R3 quota (build 3개) 초과', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
// 3개 사전 삽입.
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await ctx.deps.habitDao.insertWithVariants(HabitDraft(
|
||||
userId: kLocalDefaultUserId,
|
||||
type: HabitType.build,
|
||||
title: 'pre_$i',
|
||||
protocolId: 'morning_sunlight',
|
||||
frameLevel: FrameLevel.l2,
|
||||
frameFramedText: 'pre$i',
|
||||
startedAt: dateOnly(nowKst()),
|
||||
));
|
||||
}
|
||||
final r = await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '4번째 시도',
|
||||
}, ctx.deps);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'r3_quota');
|
||||
});
|
||||
});
|
||||
|
||||
group('list_active_habits', () {
|
||||
test('0개일 때', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final r = await listActiveHabitsTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], 0);
|
||||
expect(data['build_quota_remaining'], 3);
|
||||
expect(data['break_quota_remaining'], 1);
|
||||
});
|
||||
|
||||
test('add_habit 후 1개', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
await addHabitTool.handler({
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
}, ctx.deps);
|
||||
final r = await listActiveHabitsTool.handler({}, ctx.deps);
|
||||
expect(r, isA<ToolOk>());
|
||||
final data = (r as ToolOk).data;
|
||||
expect(data['count'], 1);
|
||||
expect(data['build_count'], 1);
|
||||
expect(data['build_quota_remaining'], 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user