[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,66 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:life_helper/ai/tools/tool_envelope.dart';
void main() {
group('ToolResult', () {
test('ToolOk JSON 형태', () {
const r = ToolOk({'a': 1});
expect(r.toJson(), {
'status': 'ok',
'data': {'a': 1}
});
});
test('ToolErr JSON 형태', () {
const r = ToolErr('validation', '잘못된 인자');
expect(r.toJson(), {
'status': 'error',
'code': 'validation',
'reason': '잘못된 인자',
});
});
test('ToolCancelled JSON 형태', () {
const r = ToolCancelled();
expect(r.toJson(), {
'status': 'cancelled',
'reason': 'user did not confirm',
});
});
});
group('encodeToolResult 2KB cap', () {
test('payload 작으면 그대로', () {
const r = ToolOk({'k': 'v'});
final s = encodeToolResult(r);
expect(jsonDecode(s), {
'status': 'ok',
'data': {'k': 'v'}
});
});
test('payload 2KB 초과 시 truncation hint 로 대체', () {
final big = ToolOk({'blob': 'x' * 5000});
final s = encodeToolResult(big);
expect(s.length, lessThan(500));
final decoded = jsonDecode(s) as Map<String, dynamic>;
expect(decoded['status'], 'ok');
final data = decoded['data'] as Map<String, dynamic>;
expect(data['_truncated'], true);
expect(data['_hint'], contains('query_protocol'));
});
test('error/cancelled 는 작아서 그대로', () {
expect(
encodeToolResult(const ToolErr('e', 'r')).length,
lessThan(100),
);
expect(
encodeToolResult(const ToolCancelled()).length,
lessThan(100),
);
});
});
}