import '../../ai/tools/tool_definition.dart'; /// Abstract LLM backend. /// /// Concrete impls: `GemmaLlmService` (flutter_gemma) and `MockLlmService` (tests). /// Contract: /// - After [load], [isLoaded] is true. /// - [generateStructured] throws [StateError] when not loaded. /// - [generateStructured] returns a parsed JSON map matching the schema. /// On schema/parse failure throws [FormatException]. /// - [unload] is idempotent. /// - [startChat] opens a multi-turn chat session for tool calling (#260). abstract class LlmService { bool get isLoaded; Future load(); Future unload(); /// Calls the model with a function-calling [schema] and returns the parsed /// JSON arguments map. Caller is responsible for applying `.timeout(...)`. Future> generateStructured( String prompt, Map schema, ); /// Opens a chat session that supports multi-turn user input + tool result /// submission with the supplied [tools]. See ADR-0005. Future startChat({ required List tools, }); } /// Streaming chat session for the tool-calling loop. /// /// Lifecycle: created by [LlmService.startChat], lives for a single chat /// screen, must be [close]d when the user dismisses the screen. Each /// `send*` call returns a stream of [LlmChatEvent]s until the model yields /// control (text done or a function call requested). abstract class LlmChatSession { Stream sendUser(String text); Stream sendToolResult({ required String toolName, required Map result, }); Future close(); } /// Events emitted by [LlmChatSession]. See ADR-0005 §C. sealed class LlmChatEvent { const LlmChatEvent(); } final class LlmTextChunk extends LlmChatEvent { final String text; const LlmTextChunk(this.text); } final class LlmFunctionCall extends LlmChatEvent { final String name; final Map args; const LlmFunctionCall(this.name, this.args); } /// Programmable stub for tests. Use [enqueueResponse] / [enqueueError]. class MockLlmService implements LlmService { final List<_Response> _queue = []; bool _loaded = false; int callCount = 0; String? lastPrompt; Map? lastSchema; Duration responseDelay = Duration.zero; /// #311 test helpers. Simulate cold-load latency / failure so the warm-up /// controller can be exercised. Mirrors the Gemma path: /// - `loadDelay > 0` → load completes after the delay /// - `loadThrows` → load throws this error /// - `loadCount` → observed by concurrent-load tests Duration loadDelay = Duration.zero; Object? loadThrows; int loadCount = 0; Future? _loadingFuture; /// Queues consumed by [startChat] in order. Each entry is the event list /// returned for a single `send*` call. final List> chatScript = []; int chatStartCount = 0; MockLlmChatSession? lastChat; @override bool get isLoaded => _loaded; /// #311 AC7: same concurrent-call guard as [GemmaLlmService]. Repeated /// in-flight `load()` calls share a single Future, so test assertions on /// `loadCount` reflect the number of native-init attempts (1), not the /// number of callers. @override Future load() { if (_loaded) return Future.value(); final existing = _loadingFuture; if (existing != null) return existing; final future = _doLoad(); _loadingFuture = future; return future.whenComplete(() { _loadingFuture = null; }); } Future _doLoad() async { loadCount += 1; if (loadDelay > Duration.zero) { await Future.delayed(loadDelay); } final err = loadThrows; if (err != null) throw err; _loaded = true; } @override Future unload() async { _loaded = false; } void enqueueResponse(Map response) { _queue.add(_Response.value(response)); } void enqueueError(Object error) { _queue.add(_Response.error(error)); } /// Enqueue one batch of events that will be emitted on the next /// `sendUser` or `sendToolResult` call. Items are streamed in order. void enqueueChatEvents(List events) { chatScript.add(events); } @override Future> generateStructured( String prompt, Map schema, ) async { callCount += 1; lastPrompt = prompt; lastSchema = schema; if (!_loaded) { throw StateError('LlmService not loaded'); } if (responseDelay > Duration.zero) { await Future.delayed(responseDelay); } if (_queue.isEmpty) { throw StateError('MockLlmService: no queued response'); } final r = _queue.removeAt(0); if (r.error != null) throw r.error!; return r.value!; } @override Future startChat({ required List tools, }) async { if (!_loaded) { throw StateError('LlmService not loaded'); } chatStartCount += 1; final session = MockLlmChatSession(chatScript); lastChat = session; return session; } } /// Mock chat session that replays pre-queued events from [MockLlmService]. class MockLlmChatSession implements LlmChatSession { final List> _script; int sendCount = 0; final List userInputs = []; final List<(String, Map)> toolResults = []; bool closed = false; MockLlmChatSession(this._script); Stream _emitNext() async* { sendCount += 1; if (_script.isEmpty) { throw StateError('MockLlmChatSession: no queued events'); } final batch = _script.removeAt(0); for (final ev in batch) { yield ev; } } @override Stream sendUser(String text) { userInputs.add(text); return _emitNext(); } @override Stream sendToolResult({ required String toolName, required Map result, }) { toolResults.add((toolName, result)); return _emitNext(); } @override Future close() async { closed = true; } } class _Response { final Map? value; final Object? error; const _Response._(this.value, this.error); factory _Response.value(Map v) => _Response._(v, null); factory _Response.error(Object e) => _Response._(null, e); }