[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:
2026-06-15 10:42:43 +09:00
parent eca097aa2c
commit b1bed4d5ca
21 changed files with 2313 additions and 0 deletions

View File

@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/habit_tools.dart';
import 'package:life_helper/ai/tools/tracker_tools.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
import '_tool_test_helpers.dart';
void main() {
group('log_tracker_entry', () {
test('정상 done 기록', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final addRes = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}, ctx.deps);
final habitId = (addRes as ToolOk).data['habit_id'] as String;
final r = await logTrackerEntryTool.handler({
'habit_id': habitId,
'value': 'done',
'date': '2026-06-15',
}, ctx.deps);
expect(r, isA<ToolOk>(), reason: '$r');
final data = (r as ToolOk).data;
expect(data['habit_id'], habitId);
expect(data['value'], 'done');
});
test('value 유효성', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await logTrackerEntryTool.handler({
'habit_id': 'whatever',
'value': 'maybe',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
test('미존재 habit_id', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await logTrackerEntryTool.handler({
'habit_id': 'hb_no_such',
'value': 'done',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'not_found');
});
test('같은 (habit_id, date) 중복 → duplicate', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final addRes = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}, ctx.deps);
final habitId = (addRes as ToolOk).data['habit_id'] as String;
await logTrackerEntryTool.handler({
'habit_id': habitId,
'value': 'done',
'date': '2026-06-15',
}, ctx.deps);
final r2 = await logTrackerEntryTool.handler({
'habit_id': habitId,
'value': 'blank',
'date': '2026-06-15',
}, ctx.deps);
expect(r2, isA<ToolErr>());
expect((r2 as ToolErr).code, 'duplicate');
});
test('date 형식 오류', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r = await logTrackerEntryTool.handler({
'habit_id': 'h',
'value': 'done',
'date': '2026/06/15',
}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'validation');
});
});
group('get_streak', () {
test('습관 없음 → not_found', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final r =
await getStreakTool.handler({'habit_id': 'hb_no'}, ctx.deps);
expect(r, isA<ToolErr>());
expect((r as ToolErr).code, 'not_found');
});
test('정상 — 기록 없을 때 0 streak', () async {
final ctx = await bootstrapToolDeps();
addTearDown(() => ctx.db.close());
final addRes = await addHabitTool.handler({
'protocol_id': 'morning_sunlight',
'frame_level': 'L2',
'framed_text': '햇빛',
}, ctx.deps);
final habitId = (addRes as ToolOk).data['habit_id'] as String;
final r =
await getStreakTool.handler({'habit_id': habitId}, ctx.deps);
expect(r, isA<ToolOk>(), reason: '$r');
final data = (r as ToolOk).data;
expect(data['current_streak'], 0);
expect(data['tier'], isNotNull);
});
});
}