Files
life-helper/docs/design/260-gemma-tool-calling/fn-chat_session_controller.md
joungmin eca097aa2c [02-Architect] #260 design spec + ADR-0005
- design/260-gemma-tool-calling/README.md — overall (12 AC + 7 OQ + 모듈 구조)
- fn-tool_dispatcher.md — multi-tool router (validate → confirm gate → handler → envelope)
- fn-add_habit_handler.md — destructive 대표 (R3/R7/R8 enforce)
- fn-confirm_gate.md — 모달 AlertDialog 흐름 (OQ-3 = 모달 확정)
- fn-chat_session_controller.md — multi-turn loop 상태 머신 (MAX_TURNS=4)
- ADR-0005 — in-app tool runtime + R 규칙 = 핸들러 책임 + schema SoT=Dart + 모달

OQ-1/2/4 = README §11 결정. OQ-3 = 모달 (사용자 결정).
신규 OQ-5/6/7 = Developer 가 구현 중 검증.

Refs #260
2026-06-15 10:15:44 +09:00

6.1 KiB

함수 설계서: ChatSessionController.userTurn (#260)

부모 설계서: ./README.md · 상태: Draft 작성: [AI] Architect · 구현: lib/state/chat_providers.dart:ChatSessionController.userTurn · 테스트: test/state/chat_session_controller_test.dart

1. 시그니처

class ChatSessionController extends StateNotifier<ChatSessionState> {
  Future<void> userTurn(String text, BuildContext context);
}

class ChatSessionState {
  final List<ChatMessage> 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<void>. 결과는 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 (25초 E2B) + handler (<100ms).
  • 총 latency: 23 turn 으로 끝나는 시나리오 평균 510 초.
  • 메모리: 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