@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_turn_start() note right of UI SessionUIBase resets the per-turn inflight buffers (_ws_inflight_content / reasoning / seq) that fuel the SSE in_progress_snapshot event for mid-stream refresh resume. end note 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 -> UI : on_turn_committed() note right of UI Drops the per-turn inflight buffers — the assistant message is now in the history list, so the in_progress_snapshot must not re-render it during the next tool- execution window or the next streaming turn. end note 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 → _run_agent() sub-loop web_fetch → httpx + LLM summarize web_search → provider-native or SearxNG 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