@startuml
!theme plain
title Turnstone — Conversation Turn Lifecycle

skinparam sequenceArrowThickness 1.5
skinparam sequenceLifeLineBackgroundColor #F5F5F5

participant "User /\nHTTP Client" as User
participant "ChatSession" as CS
participant "SessionUI" as UI
participant "LLMProvider\n(OpenAI / Anthropic)" as LLM
participant "Tool Executor\n(ThreadPool)" as TP
database "SQLite" as DB

== User Input ==

User -> CS : send(user_input)
activate CS

CS -> CS : messages.append({role: "user", content: input})
CS -> DB : save_message(ws_id, "user", input)

== LLM Call Loop ==

group loop [while tool_calls present]

    CS -> UI : on_state_change("thinking")
    CS -> UI : on_thinking_start()

    CS -> LLM : provider.create_streaming(\n  client, model, messages, tools, ...)\n  (normalized StreamChunk iterator)
    activate LLM

    note right of CS
      Retry up to 3× on transient errors:
      RateLimitError, APITimeoutError,
      APIConnectionError, InternalServerError,
      ServiceUnavailableError, APIError
      Backoff: 1s, 2s, 4s
    end note

    == Streaming Response ==

    loop for each chunk in stream
        LLM --> CS : delta
        note right of CS
          on_thinking_stop() called on first
          delta token via _stop_spinner_once()
        end note
        alt reasoning_content present
            CS -> UI : on_reasoning_token(text)
        else content present
            CS -> UI : on_content_token(text)
        else tool_call delta
            CS -> CS : accumulate in tool_calls_acc
        else info_delta present
            CS -> UI : on_info(text)\n(e.g. server-side web search status)
        end
    end

        note right of CS
          **Cancellation checkpoint:**
          _check_cancelled() runs per chunk.
          If cancel_event is set, raises
          GenerationCancelled — preserves
          partial content, emits idle state.
        end note

    LLM --> CS : stream complete (usage stats)
    deactivate LLM

    CS -> UI : on_thinking_stop()  (no-op guard: already called by _stop_spinner_once)
    CS -> UI : on_stream_end()

    CS -> CS : _update_token_table()\ncalibrate chars_per_token ratio
    CS -> CS : messages.append(assistant_msg)
    CS -> DB : save_message(ws_id, "assistant", content)
    CS -> DB : save_message(ws_id, "tool_call", ...) ×N

    == Tool Dispatch (if tool_calls) ==

    alt no tool_calls
        CS -> UI : on_status(usage, context_window, effort)

        opt prompt_tokens > context_window × auto_compact_pct
            CS -> CS : _compact_messages(auto=True)
            CS -> LLM : Non-streaming summarization call
            CS -> CS : Replace messages with [summary]
        end

        opt first exchange & no title
            CS -> CS : Background thread: _generate_title()
        end

        CS -> UI : on_state_change("idle")
        CS --> User : return

    else has tool_calls
        CS -> UI : on_state_change("running")

        == Phase 1: Prepare ==
        CS -> CS : [_prepare_tool(tc) for tc in tool_calls]\nParse JSON args, validate,\nbuild preview + header

        == Phase 2: Approve ==
        CS -> UI : on_state_change("attention")
        CS -> UI : approve_tools(items)
        activate UI
        note right of UI
          TerminalUI: input() prompt
          WebUI: _approval_event.wait()
          NullUI: returns (True, None)
        end note
        UI --> CS : (approved: bool, feedback: str?)
        deactivate UI
        CS -> UI : on_state_change("running")

        == Phase 3: Execute ==
        CS -> TP : ThreadPoolExecutor(max_workers=4)\nrun_one(item) for each tool
        activate TP

        note right of TP
          Parallel execution:
          bash → Popen + line-by-line streaming
          read_file → open().read() or base64 image
          search → grep subprocess
          edit_file → string replace
          task/plan → _run_agent() sub-loop
          math → sandboxed subprocess
          web_fetch → httpx + LLM summarize
          web_search → provider-native or Tavily fallback
          memory/recall → SQLite
        end note

        note right of TP
          bash: on_tool_output_chunk(call_id, line)
          called per stdout line,
          then on_tool_result(call_id, name, output, is_error).
          is_error=True when execution failed.
          call_id routes chunks/results to correct
          tool div during parallel execution.
          Other tools: on_tool_result() only.
        end note

        TP --> CS : [(call_id, output), ...]
        deactivate TP

        loop for each result
            CS -> CS : messages.append({role: "tool", ...})
            CS -> DB : save_message(ws_id, "tool_result", ...)
        end

        opt user_feedback from approval
            CS -> CS : messages.append({role: "user", content: feedback})
        end

        note right of CS : Loop back for next LLM call

    else GenerationCancelled
        CS -> CS : Preserve partial content\nor roll back incomplete tools
        CS -> UI : on_info("[Generation cancelled]")
        CS -> UI : on_state_change("idle")
        CS --> User : return (no re-raise)
    end

end

deactivate CS

@enduml
