Chat Stream & Tool Usage Flow
A Mermaid sequence diagram of one user-message → tool-call → response cycle, with every step verified against the current implementation. The example walks through a write_file call because it’s the simplest single-tool case to follow — the agent actually has 15 tools available (see src-tauri/src/agent/tools.rs); the same Channel/streaming/direct-store-write mechanics apply regardless of which one the model picks.
Legend
| Color | Event type |
|---|---|
| 🔵 | Setup / IPC call |
| 🟢 | Streaming chunk |
| 🟠 | Tool call |
| 🔴 | Tool result |
| 🟣 | Browser paint |
| ⚪ | Finalization |
Main Sequence Diagram
sequenceDiagram
autonumber
participant U as User
participant R as "React / useChat.ts"
participant B as "Browser / DOM"
participant IPC as "Tauri Channel"
participant RS as "Rust / Tokio"
participant OA as "Ollama API"
%% ════════════════════════════════════════════════════════
%% PHASE 1 — MESSAGE DISPATCH
%% ════════════════════════════════════════════════════════
U->>R: sendMessage()
activate R
R->>R: append user msg + assistant placeholder
R->>R: setStreaming(true) · clear thinking
R->>R: buildApiMessages()<br/>Ollama format: tool_calls + tool_name
Note right of R: src/hooks/useChat.ts:23–75,203
R->>IPC: invoke("generate_completion_stream", …)
activate IPC
IPC->>RS: generate_completion_stream
activate RS
RS->>RS: CancellationToken · register request_id
RS->>RS: tokio::spawn(detached task)
Note right of RS: src-tauri/src/commands/ai.rs:461–550
RS-->>IPC: return request_id
deactivate RS
IPC-->>R: request_id
deactivate IPC
R->>R: activeRequestId.current = id
deactivate R
%% ════════════════════════════════════════════════════════
%% PHASE 2 — AGENT LOOP SETUP
%% ════════════════════════════════════════════════════════
RS->>RS: run_agent_loop()
activate RS
RS->>RS: build_tools() → 15 ToolInfo defs (tools.rs:160)
RS->>RS: stream_turn() — iteration 0
Note right of RS: src-tauri/src/agent/agent_loop.rs:118–124
RS->>OA: POST /api/chat<br/>stream: true · tools · think?
%% ════════════════════════════════════════════════════════
%% PHASE 3 — STREAMING CHUNKS
%% ════════════════════════════════════════════════════════
loop Per NDJSON chunk
OA-->>RS: { message: { content, thinking }, done: false }
RS->>IPC: send(Chunk { text, thinking })
IPC->>R: onmessage(Chunk)
activate R
R->>R: rAF batch → setStreamingContent + setStreamingThinking
Note right of R: src/hooks/useChat.ts:288–307
R->>B: render <Reasoning> + content
Note right of B: 🟣 PAINT — thinking / text visible
deactivate R
end
%% ════════════════════════════════════════════════════════
%% PHASE 4 — TOOL CALL DECISION
%% ════════════════════════════════════════════════════════
Note over B: isEmpty = isStreaming && content==="" && !streamingThinking<br/>If both empty → Loader variant="typing"
Note right of B: src/components/chat/MessageList.tsx:135,175–176
OA-->>RS: done=true · tool_calls=[write_file]
RS->>RS: assistant_msg.tool_calls = tool_calls
RS->>RS: push assistant_msg to history
Note right of RS: agent_loop.rs:47–55,169–172
%% ════════════════════════════════════════════════════════
%% PHASE 5 — TOOL EXECUTION
%% ════════════════════════════════════════════════════════
RS->>IPC: send(ToolCall { tool, args })
RS->>RS: execute_tool("write_file")
Note right of RS: Model-given path wins if present, sandboxed to project_dir<br/>(rejects "..", falls back to caller output_path)<br/>src-tauri/src/agent/executor.rs:99-145
RS->>RS: tokio::fs::write(project_dir, content)
RS->>IPC: send(ToolResult { tool, success, output, path, content })
RS->>RS: wrote_file = true . break
RS->>RS: history.push(tool result)
RS->>IPC: send(Done)
deactivate RS
%% ════════════════════════════════════════════════════════
%% PHASE 6 — STORE WRITES
%% ════════════════════════════════════════════════════════
Note over IPC: Tauri Channel while-loop (core.js:99-105) drains<br/>ToolCall to ToolResult to Done synchronously in ONE JS task.<br/>attachToolCall/resolveToolCall write straight to Zustand as each event arrives.
activate R
IPC->>R: onmessage(ToolCall)
R->>R: attachToolCall(entityId, tool, "", args)<br/>pushes { tool, arguments, pending: true } onto toolCalls[]
Note right of R: src/stores/chatStore.ts:77-86<br/>src/hooks/chat/streamHandler.ts:94-96
IPC->>R: onmessage(ToolResult)
R->>R: resolveToolCall(entityId, tool, output, success, path)<br/>finds first pending entry matching tool, sets<br/>{ result, success, pending: false, path }
Note right of R: Front-to-back search assumes ToolCall/ToolResult<br/>arrive in matching index order (chatStore.ts:88-105)
R->>R: if write_file/edit_file succeeded:<br/>onCodeOutput(content) . onToolWrite(path, content)
Note right of R: src/hooks/chat/streamHandler.ts:141-159
IPC->>R: onmessage(Done)
R->>R: finalize(content, thinking)<br/>setMessages . setStreaming(false) . writeFile(chat.json)
Note right of R: src/hooks/chat/streamHandler.ts:53-71,160-172
deactivate R
%% ════════════════════════════════════════════════════════
%% PHASE 7 — RENDER
%% ════════════════════════════════════════════════════════
R->>B: Zustand notifies subscribers, React re-renders
activate B
R->>B: render Tool card . state derives from { pending, success }
Note right of B: PAINT - pending: true gives "input-streaming" (spinner)<br/>pending: false, success: true gives "output-available"<br/>pending: false, success: false gives "output-error"<br/>src/components/ui/tool.tsx:15-27,171-187
deactivate B
%% ════════════════════════════════════════════════════════
%% MULTI-TURN CONTINUATION
%% ════════════════════════════════════════════════════════
Note over RS: If wrote_file == false:<br/>rebuild request(vec![]) . stream_turn() again<br/>MAX_ITERATIONS = 20 (agent_loop.rs:17)
Tool Card State Machine
stateDiagram-v2
[*] --> idle: Initial state
idle --> input_streaming: onmessage(ToolCall)<br/>attachToolCall(...)<br/>pushes { pending: true }
input_streaming --> output_available: onmessage(ToolResult)<br/>resolveToolCall(... success: true)<br/>mutates entry in place: { pending: false, success: true }
input_streaming --> output_error: onmessage(ToolResult)<br/>resolveToolCall(... success: false)<br/>{ pending: false, success: false }
output_available --> [*]: Render complete
output_error --> [*]: Render complete
tool.tsx derives the rendered state directly from { pending, success } on each toolCalls[] entry — there’s no separate UI-state field to keep in sync (tool.tsx:15–27,171–187).
Verified Implementation Details
Tauri Channel Batching
The @tauri-apps/api/core.js Channel class queues out-of-order messages and drains them synchronously (core.js:99–105, transpiled output — matches the Tauri v2 IPC source):
while (nextIndex in pendingMessages) {
const message = pendingMessages[nextIndex];
onmessage.call(this, message);
delete pendingMessages[nextIndex];
nextIndex++;
}
If Rust sends ToolCall → ToolResult → Done in rapid succession, all three onmessage handlers can run in one uninterruptible JS task. attachToolCall and resolveToolCall write straight to the Zustand store as each event arrives (streamHandler.ts:94–96,144), and tool.tsx derives the rendered card state directly from { pending, success } — see Tool Card State Machine above.
Tokio CancellationToken + tokio::select!
Cancellation is cooperative. The CancellationToken is cloned: one copy stored in AppState.cancellation_tokens (HashMap), one moved into the spawned task. When the frontend calls stop_generation_stream(request_id), the original token’s .cancel() method is called, which resolves .cancelled() in the spawned task, causing tokio::select! to drop the HTTP stream and close the TCP connection.
Per tokio-util docs: CancellationToken supports clone + cancel for cooperative cancellation.
Per Ollama API docs: There is no /api/abort endpoint. Dropping the connection is the standard cancellation pattern.
ollama-rs History Helper Limitation
send_chat_messages_with_history_stream accumulates text content and pushes ChatMessage::assistant(content) — it does not preserve tool_calls in the assistant history entry. Without the manual fix at agent_loop.rs:47–55,169–172, multi-turn tool conversations break because Ollama receives a tool role message with no prior tool_calls in the assistant message.
Write-File Path Resolution
execute_write_file (executor.rs:99–145) prefers the model-given path when the model supplies one: it’s resolved against project_dir, rejected if it contains a .. segment or resolves outside project_dir, and falls back to the caller’s output_path parameter only when the model didn’t provide a usable path. This sandboxes against path-traversal (the model hallucinating something like "../../../etc/passwd") while still letting the model name and place the file it’s writing. Parent directories are auto-created via tokio::fs::create_dir_all.
Two Ollama Paths
generate_ollama_completion_stream (ai.rs:229) branches on output_path at line 237:
output_pathpresent → agent loop path viaollama-rs(tool calling, manages its own history)output_pathnull → direct HTTP path (reqwest+ raw JSON). Used when model capabilities don’t include tools. The direct path builds JSON manually viamessages_to_ollama_jsonto support thetool_namefield, which ollama-rsChatMessagelacks.
File Index
| File | Role |
|---|---|
src/hooks/useChat.ts |
Main React hook: sends messages, owns streaming state |
src/hooks/chat/streamHandler.ts |
createStreamHandler: Channel onmessage handlers, finalize |
src/stores/chatStore.ts |
Zustand store: attachToolCall, resolveToolCall |
src/lib/ipc.ts |
generateCompletionStream wrapper, CompletionEvent type |
src-tauri/src/commands/ai.rs |
CompletionEvent enum, command registration, Ollama provider branching |
src-tauri/src/agent/agent_loop.rs |
run_agent_loop, stream_turn, manual history fix |
src-tauri/src/agent/executor.rs |
execute_tool, execute_write_file, execute_read_file, execute_bash |
src/types/chat.ts |
ChatMessage, ToolCallRecord types |
src/components/chat/MessageList.tsx |
Message rendering: reasoning blocks, tool cards, loader states |
src/components/ui/tool.tsx |
Generic tool UI: input-streaming, output-available, output-error states |
node_modules/@tauri-apps/api/core.js |
Tauri v2 Channel IPC: transformCallback, pendingMessages queue |