[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:
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
114
app/test/ai/tools/tool_dispatcher_test.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
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';
|
||||
|
||||
import '_tool_test_helpers.dart';
|
||||
|
||||
void main() {
|
||||
group('ToolDispatcher', () {
|
||||
test('unknown tool 이름', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'no_such',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'unknown_tool');
|
||||
});
|
||||
|
||||
test('validation: required 없음', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'query_protocol',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('validation: 타입 불일치', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'query_protocol',
|
||||
rawArgs: const {'id': 123},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'validation');
|
||||
});
|
||||
|
||||
test('destructive + null confirmContext → ToolCancelled', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'add_habit',
|
||||
rawArgs: const {
|
||||
'protocol_id': 'morning_sunlight',
|
||||
'frame_level': 'L2',
|
||||
'framed_text': '햇빛',
|
||||
},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolCancelled>());
|
||||
});
|
||||
|
||||
test('read-only normal 경로', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final dispatcher =
|
||||
ToolDispatcher(registry: ToolRegistry.defaults());
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'search_catalog',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolOk>());
|
||||
});
|
||||
|
||||
test('handler 예외 → ToolErr(handler_error)', () async {
|
||||
final ctx = await bootstrapToolDeps();
|
||||
addTearDown(() => ctx.db.close());
|
||||
final throwing = ToolDefinition(
|
||||
name: 'always_throws',
|
||||
description: 'test',
|
||||
parametersSchema: const {
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'required': [],
|
||||
},
|
||||
handler: (_, _) async => throw StateError('boom'),
|
||||
);
|
||||
final dispatcher = ToolDispatcher(
|
||||
registry: ToolRegistry([throwing]),
|
||||
);
|
||||
final r = await dispatcher.dispatch(
|
||||
toolName: 'always_throws',
|
||||
rawArgs: const {},
|
||||
confirmContext: null,
|
||||
deps: ctx.deps,
|
||||
);
|
||||
expect(r, isA<ToolErr>());
|
||||
expect((r as ToolErr).code, 'handler_error');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user