# 함수 설계서: `ChatSessionController.userTurn` (#260) > **부모 설계서**: ./README.md · **상태**: Draft > **작성**: [AI] Architect · **구현**: `lib/state/chat_providers.dart:ChatSessionController.userTurn` · **테스트**: `test/state/chat_session_controller_test.dart` ## 1. 시그니처 ```dart class ChatSessionController extends StateNotifier { Future userTurn(String text, BuildContext context); } class ChatSessionState { final List messages; // append-only in-memory final bool isStreaming; // 모델 응답 중 → 입력 disabled final String? streamingText; // 부분 텍스트 누적 final String? error; // 마지막 에러 (null = OK) } ``` ## 2. 책임 (단일 책임, 1줄) 사용자 메시지를 받아 LLM ↔ Tool Dispatcher 의 multi-turn loop 을 돌리고, 메시지 상태를 갱신한다. ## 3. 입력 | 파라미터 | 타입 | 제약 | 설명 | |---|---|---|---| | `text` | String | non-empty after trim | 사용자가 입력한 자연어 | | `context` | BuildContext | mounted | ConfirmGate 가 사용 | ## 4. 출력 - **반환**: `Future`. 결과는 state.messages 와 streamingText 에 반영. - **부수효과**: - state 다중 갱신 (StateNotifier.state = ...) - LLM 호출 (I/O) - Tool dispatcher 호출 (DB write 가능) - ConfirmGate 모달 표시 가능 ## 5. 동작 / 알고리즘 ``` 1. 입력 validate: if text.trim().isEmpty: return if state.isStreaming: return // 중복 호출 차단 2. user 메시지 append: state = state.copyWith( messages: [...state.messages, UserChatMessage(text)], isStreaming: true, streamingText: '', error: null, ) 3. tool registry 와 deps 준비: tools = ToolRegistry.allDefinitions() deps = ref.read(toolDepsProvider) llm = ref.read(llmServiceProvider) 4. multi-turn loop (최대 MAX_TURNS=4 — tool 호출 chain 보호): for (var turn = 0; turn < MAX_TURNS; turn++) { stream = llm.sendChatTurn( userInput: turn == 0 ? text : null, // 0 turn 만 user text, 이후는 tool result toolResultToSubmit: turn == 0 ? null : pendingToolResult, tools: tools, ) toolCallToHandle = null accumulatedText = '' await for (event in stream) { switch event: case TextResponse(text): accumulatedText += text state = state.copyWith(streamingText: accumulatedText) case FunctionCallResponse(name, args): toolCallToHandle = (name, args) break // 스트림 stop, tool 처리로 분기 case ThinkingResponse: skip } if toolCallToHandle == null: // 모델이 자연어 응답으로 마무리 state = state.copyWith( messages: [...state.messages, ModelChatMessage(accumulatedText)], streamingText: null, isStreaming: false, ) return // tool 처리 result = await ToolDispatcher.dispatch( toolName: toolCallToHandle.name, rawArgs: toolCallToHandle.args, confirmContext: context, deps: deps, ) state = state.copyWith( messages: [...state.messages, ToolCallChatMessage(toolCallToHandle.name, toolCallToHandle.args, result)], streamingText: '', ) pendingToolResult = (toolCallToHandle.name, result.toJson()) } // MAX_TURNS 초과 → 안전 종료 state = state.copyWith( error: '도구 호출 루프가 너무 길어 중단했습니다.', isStreaming: false, streamingText: null, ) ``` ## 6. 에러 & 실패 모드 | 조건 | 처리 | state | |---|---|---| | 빈 입력 | early return | unchanged | | 동시 호출 (isStreaming) | early return | unchanged | | LLM stream 예외 | catch | `error: 'LLM 응답 실패: ${e.type}'`, isStreaming:false | | MAX_TURNS 초과 | safety break | `error: '...너무 길어...'` | | tool result 직렬화 실패 (이론상 없음) | catch | tool ToolErr 대체 | ## 7. 엣지케이스 - **사용자가 stream 중 chat 화면 dismiss**: StateNotifier 가 dispose 되어도 진행 중인 `await` 는 계속됨. side effect (DB write) 가 이미 시작됐을 수 있으니 graceful 하게 무시 — `mounted` 체크로 state 갱신만 skip. - **연속 tool 호출**: LLM 이 search_catalog → query_protocol → add_habit 같이 3 turn 돌 수 있음. MAX_TURNS=4 가 안전망. AC 시나리오 대부분 1~2 turn 종료. - **tool 호출 후 LLM 이 또 같은 tool 호출 (loop)**: MAX_TURNS 가 차단. 추가로 핸들러는 idempotent 결과 반환하지만 R3 quota 등이 2번째 호출에서 차단. - **chat history 8 turn 초과**: 현재 turn 끝나면 "지난 대화를 정리할까요?" 안내 메시지 (`SystemChatMessage`) 자동 append. clear 는 사용자 액션. - **모달 confirm 대기 중 사용자가 화면 dismiss**: ConfirmGate 내부 mounted 가드가 false 반환 → ToolCancelled → loop 계속 (또는 LLM 이 마무리). ## 8. 복잡도 / 성능 - per-turn: LLM round trip (~2~5초 E2B) + handler (<100ms). - 총 latency: 2~3 turn 으로 끝나는 시나리오 평균 5~10 초. - 메모리: messages 리스트가 메모리 누적. clear 안 하면 무한 — 단 chat history persist X 이므로 앱 종료 시 GC. ## 9. 테스트 케이스 (필수) Mock LLM 으로 시뮬레이션. 실 모델 호출 안 함. | 케이스 | LLM mock 시퀀스 | 기대 state | |---|---|---| | 자연어 응답만 | `[TextResponse('안녕!')]` | messages = [user, model], isStreaming=false | | 1 tool call + 응답 | `[FunctionCallResponse('search_catalog', {category:'sleep'})]` → tool result → `[TextResponse('카페인 protocol...')]` | messages = [user, toolCall, model] | | destructive cancel | `add_habit` call → ConfirmGate mock false | toolCall message 의 result = ToolCancelled | | MAX_TURNS 초과 | LLM 이 매번 tool call | error 세팅, 안전 종료 | | 중복 호출 차단 | isStreaming=true 일 때 userTurn 재호출 | early return, state unchanged | ## 10. 의존 - `LlmService.sendChatTurn(...)` (확장 인터페이스) - `ToolDispatcher.dispatch(...)` - `ToolRegistry.allDefinitions()` - `ToolDeps` (toolDepsProvider) - Flutter `BuildContext.mounted`