diff --git a/docker-compose.yml b/docker-compose.yml index b21708e..d994ee2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - ~/.config/oli:/app/.config/oli - ./oli_bot/profiles:/usr/local/lib/python3.12/site-packages/oli_bot/profiles - oli-logs:/app/logs + - ~/repos:/app/repos volumes: oli-logs: diff --git a/docs/API_SERVER.md b/docs/API_SERVER.md index ee31cde..903b871 100644 --- a/docs/API_SERVER.md +++ b/docs/API_SERVER.md @@ -309,7 +309,10 @@ Management endpoints backing the browser UI's settings view. They read and write translating between the nested settings format and a flat key-per-`AppConfig`- field shape (`openai_model`, `truncation_max_chars_small`, `api_port`, ...). -`GET /v1/config` returns the current flat config: +`GET /v1/config` returns the current flat config — one key per +`settings.json` leaf, so every setting the TUI's `/config` screen edits is +reachable here (`session_*`, `workspace_max_workspaces`, `ollama_context_window`, +`first_chunk_timeout`, `openai_responses_enabled`, ...): ```bash curl http://localhost:9734/v1/config diff --git a/oli_bot/activity.py b/oli_bot/activity.py new file mode 100644 index 0000000..87a9a63 --- /dev/null +++ b/oli_bot/activity.py @@ -0,0 +1,250 @@ +"""Turn activity capture — records *what happened* during an agent turn. + +The persisted transcript only says *what was said*: the assistant text, the +tool-call requests and the tool results. The model reasoning, the per-call +timings and the dispatched sub-agent runs are not part of the message list +because they are not part of the conversation the model sees. + +This module records them so a reopened session can rebuild the same view the +live UI showed. The result is serialized onto the message that closes a turn +(see ``Message.activity``) and read back by clients; it is never sent to the +model, because backends build their provider payload field by field. +""" + +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .models import ( + Message, + StreamChunk, + SubAgentCompleted, + SubAgentEvent, + SubAgentProgress, + SubAgentStarted, + ToolCallExecuting, + ToolCallResult, +) + + +@dataclass +class ActivityToolCall: + """One tool invocation performed during a turn.""" + + id: str + name: str + parameters: Dict[str, Any] = field(default_factory=dict) + result: Optional[str] = None + elapsed: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "parameters": self.parameters, + "result": self.result, + "elapsed": self.elapsed, + } + + +@dataclass +class SubAgentRecord: + """A dispatched sub-agent run and everything it produced.""" + + task_id: str + agent_name: str + pool_name: str = "default" + task: str = "" + status: str = "running" + activity: str = "" + full_text: str = "" + text: str = "" + tool_calls: List[Dict[str, Any]] = field(default_factory=list) + todos: List[dict] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + return { + "task_id": self.task_id, + "agent_name": self.agent_name, + "pool_name": self.pool_name, + "task": self.task, + "status": self.status, + "activity": self.activity, + "full_text": self.full_text, + "text": self.text, + "tool_calls": self.tool_calls, + "todos": self.todos, + } + + +class TurnActivity: + """Accumulates the activity of a single agent turn. + + Thinking text is tracked as *segments* — a segment ends when the model + switches to text or to a tool call — and the segments are joined with a + blank line, mirroring how the clients group streamed thinking blocks. + """ + + def __init__(self) -> None: + self._started = time.perf_counter() + self._thinking_segment = "" + self._thinking_segments: List[str] = [] + self._tool_calls: List[ActivityToolCall] = [] + self._tool_calls_by_id: Dict[str, ActivityToolCall] = {} + self._sub_agents: List[SubAgentRecord] = [] + self._sub_agents_by_id: Dict[str, SubAgentRecord] = {} + + # ── thinking ────────────────────────────────────────────────────── + + def add_thinking(self, text: str) -> None: + self._thinking_segment += text + + def _close_thinking_segment(self) -> None: + if self._thinking_segment: + self._thinking_segments.append(self._thinking_segment) + self._thinking_segment = "" + + def close_thinking_segment(self) -> None: + """End the current thinking segment (the model changed mode).""" + self._close_thinking_segment() + + @property + def thinking(self) -> str: + segments = list(self._thinking_segments) + if self._thinking_segment: + segments.append(self._thinking_segment) + return "\n\n".join(s.strip() for s in segments if s.strip()) + + # ── tool calls ──────────────────────────────────────────────────── + + def start_tool_call( + self, call_id: str, name: str, parameters: Dict[str, Any] + ) -> None: + call = ActivityToolCall(id=call_id, name=name, parameters=parameters or {}) + self._tool_calls.append(call) + if call_id: + self._tool_calls_by_id[call_id] = call + + def finish_tool_call(self, call_id: str, result: str, elapsed: float) -> None: + call = self._tool_calls_by_id.get(call_id) + if call is None: + # Fall back to the most recent call still awaiting a result. + for candidate in reversed(self._tool_calls): + if candidate.result is None: + call = candidate + break + if call is None: + return + call.result = result + call.elapsed = elapsed + + @property + def tool_calls(self) -> List[ActivityToolCall]: + return self._tool_calls + + # ── sub-agents ──────────────────────────────────────────────────── + + def observe(self, event: Any) -> None: + """Fold a sub-agent lifecycle event into the activity.""" + if isinstance(event, SubAgentStarted): + record = SubAgentRecord( + task_id=event.task_id, + agent_name=event.agent_name, + pool_name=event.pool_name, + task=event.task, + status="running", + ) + self._sub_agents.append(record) + self._sub_agents_by_id[event.task_id] = record + return + if isinstance(event, SubAgentEvent): + record = self._sub_agents_by_id.get(event.task_id) + if record is not None: + self._observe_inner(record, event.event) + return + if isinstance(event, SubAgentProgress): + record = self._sub_agents_by_id.get(event.task_id) + if record is not None: + record.activity = event.activity + record.status = event.status + return + if isinstance(event, SubAgentCompleted): + record = self._sub_agents_by_id.get(event.task_id) + if record is not None: + record.status = event.status + record.activity = event.status + record.full_text = event.full_text + if event.todos: + record.todos = list(event.todos) + return + + @staticmethod + def _observe_inner(record: SubAgentRecord, event: Any) -> None: + if isinstance(event, StreamChunk): + record.text += event.text + elif isinstance(event, ToolCallExecuting): + record.tool_calls.append( + {"name": event.name, "parameters": event.parameters or {}} + ) + elif isinstance(event, ToolCallResult): + for call in record.tool_calls: + if "result" not in call: + call["result"] = event.result + break + + @property + def sub_agents(self) -> List[SubAgentRecord]: + return self._sub_agents + + # ── serialization ───────────────────────────────────────────────── + + def elapsed(self) -> float: + return time.perf_counter() - self._started + + def is_empty(self) -> bool: + return not (self.thinking or self._tool_calls or self._sub_agents) + + def to_dict(self, elapsed: Optional[float] = None) -> Optional[Dict[str, Any]]: + """Serialize the activity, or ``None`` when there is nothing to record.""" + if self.is_empty(): + return None + return { + "thinking": self.thinking, + "elapsed": round(self.elapsed() if elapsed is None else elapsed, 3), + "tool_calls": [call.to_dict() for call in self._tool_calls], + "sub_agents": [run.to_dict() for run in self._sub_agents], + } + + +def close_thinking_segment(activity: TurnActivity) -> None: + """End the current thinking segment (called when the model changes mode).""" + activity.close_thinking_segment() + + +def attach_turn_activity( + messages: List[Message], turn_start: int, activity: Optional[Dict[str, Any]] +) -> bool: + """Attach a turn's activity to the message that closes the turn. + + The activity rides on an existing message instead of a dedicated one + because an empty assistant message would be rejected by strict provider + validators when the conversation is replayed. The last assistant-or-tool + message of the turn is chosen: the final assistant reply when the turn + produced one, otherwise the last tool result. Returns whether anything + was attached. + """ + if not activity: + return False + for message in reversed(messages[turn_start:]): + if message.role in ("assistant", "tool"): + message.activity = activity + return True + return False + + +__all__ = [ + "ActivityToolCall", + "SubAgentRecord", + "TurnActivity", + "attach_turn_activity", +] diff --git a/oli_bot/agent.py b/oli_bot/agent.py index 07ee89b..355be9a 100644 --- a/oli_bot/agent.py +++ b/oli_bot/agent.py @@ -6,10 +6,12 @@ import json import yaml import logging +import time from datetime import datetime from pathlib import Path from typing import Any, AsyncIterator, Callable, Dict, List, Optional +from .activity import TurnActivity from .backends import ( Message, TextChunk, @@ -281,6 +283,7 @@ def _compose_messages(self, messages: List[Message]) -> List[Message]: name=first.name, timestamp=first.timestamp, tool_call_id=first.tool_call_id, + activity=first.activity, ) else: composed.insert(0, Message(role="system", content=header)) @@ -314,6 +317,7 @@ async def _tool_loop( confirm_callback: Optional[Callable[[str], Any]], ) -> AsyncIterator[AgentEvent]: run_usage = Usage() + activity = TurnActivity() for _ in range(self.config.max_tool_iterations): stream = self.backend.stream_generate( messages, tools=tools if tools else [] @@ -345,13 +349,16 @@ async def _tool_loop( break _first_chunk = False if isinstance(event, TextChunk): + activity.close_thinking_segment() full_response += event.text yield StreamChunk(event.text) elif isinstance(event, ThinkingChunk): + activity.add_thinking(event.text) yield event elif isinstance(event, UsageChunk): run_usage = _merge_usage(run_usage, event.usage) elif isinstance(event, ToolCallChunk): + activity.close_thinking_segment() tool_calls = event.tool_calls for tc in tool_calls: logger.debug( @@ -374,7 +381,7 @@ async def _tool_loop( yield Error(err_text) # Emit an empty Done so the UI layer's "skip empty assistant # append" branch fires and error text never poisons history. - yield Done(full_text="") + yield Done(full_text="", activity=activity.to_dict()) return except Exception as e: msg = str(e) @@ -387,7 +394,7 @@ async def _tool_loop( err_text = f"Error: {msg}" logger.exception("Streaming failed in tool loop: %s", e) yield Error(err_text) - yield Done(full_text="") + yield Done(full_text="", activity=activity.to_dict()) return finally: # Always close the backend stream so sockets/tasks held by @@ -401,7 +408,7 @@ async def _tool_loop( if not tool_calls: if run_usage.total_tokens: yield UsageEvent(usage=run_usage) - yield Done(full_text=full_response) + yield Done(full_text=full_response, activity=activity.to_dict()) return if full_response: @@ -428,6 +435,8 @@ async def _tool_loop( for tc in tool_calls: yield ToolCallExecuting(name=tc.name, parameters=tc.parameters) + activity.start_tool_call(tc.id, tc.name, tc.parameters) + call_started = time.perf_counter() try: if tc.name == "builtin__dispatch": result_holder: List[str] = [""] @@ -437,6 +446,7 @@ async def _tool_loop( confirm_callback, result_holder, ): + activity.observe(_ev) yield _ev result = result_holder[0] else: @@ -449,6 +459,9 @@ async def _tool_loop( except Exception as e: logger.exception("Tool call '%s' failed", tc.name) result = f"Error: {e}" + activity.finish_tool_call( + tc.id, result, time.perf_counter() - call_started + ) yield ToolCallResult(name=tc.name, result=result) messages.append( Message(role="tool", content=result, tool_call_id=tc.id) @@ -540,6 +553,7 @@ async def _final_stream( errored = False usage = Usage() stream = None + activity = TurnActivity() try: # Forward tools so backends that require a matching tool schema # for any toolUse/toolResult blocks in history (e.g. Bedrock via @@ -561,9 +575,11 @@ async def _final_stream( break _first_chunk = False if isinstance(event, TextChunk): + activity.close_thinking_segment() full_response += event.text yield StreamChunk(event.text) elif isinstance(event, ThinkingChunk): + activity.add_thinking(event.text) yield event elif isinstance(event, UsageChunk): usage = _merge_usage(usage, event.usage) @@ -596,13 +612,13 @@ async def _final_stream( if errored: # Keep error text out of persisted history; the Error event # already drove the red UI panel. - yield Done(full_text="") + yield Done(full_text="", activity=activity.to_dict()) else: if not full_response: full_response = "The model completed all tool calls but did not produce a final summary." if usage.total_tokens: yield UsageEvent(usage=usage) - yield Done(full_text=full_response) + yield Done(full_text=full_response, activity=activity.to_dict()) def _expand_env( @@ -710,6 +726,7 @@ async def emit(event: AgentEvent) -> None: agent_name=run.agent_name, status=run.status, full_text=run.full_text, + todos=list(run.todos), ) ) return full_text @@ -770,6 +787,7 @@ async def run_task(idx: int) -> tuple[str, str]: agent_name=run.agent_name, status="error", full_text="", + todos=list(run.todos), ) ) return run.agent_name, f"Error: {e}" diff --git a/oli_bot/api/convert.py b/oli_bot/api/convert.py index 2aeb657..9817663 100644 --- a/oli_bot/api/convert.py +++ b/oli_bot/api/convert.py @@ -122,7 +122,10 @@ def _event_to_frame(event: "AgentEvent") -> Dict[str, Any]: if isinstance(event, Error): return {"type": "error", "data": {"message": event.message}} if isinstance(event, Done): - return {"type": "done", "data": {"full_text": event.full_text}} + return { + "type": "done", + "data": {"full_text": event.full_text, "activity": event.activity}, + } if isinstance(event, SubAgentStarted): return { "type": "sub_agent_started", @@ -151,6 +154,7 @@ def _event_to_frame(event: "AgentEvent") -> Dict[str, Any]: "agent_name": event.agent_name, "status": event.status, "full_text": event.full_text, + "todos": list(event.todos), }, } logger.warning("Unknown agent event in websocket relay: %r", event) diff --git a/oli_bot/api/routers/config.py b/oli_bot/api/routers/config.py index fcd0842..46b7c99 100644 --- a/oli_bot/api/routers/config.py +++ b/oli_bot/api/routers/config.py @@ -18,9 +18,11 @@ "openai_small_model": ("openai", "small_model"), "openai_vision_style": ("openai", "vision_style"), "openai_optional_headers": ("openai", "optional_headers"), + "openai_responses_enabled": ("openai", "responses_enabled"), "ollama_base_url": ("ollama", "base_url"), "ollama_model": ("ollama", "large_model"), "ollama_small_model": ("ollama", "small_model"), + "ollama_context_window": ("ollama", "context_window"), "huggingface_base_url": ("huggingface", "base_url"), "huggingface_api_key": ("huggingface", "api_key"), "huggingface_model": ("huggingface", "large_model"), @@ -46,6 +48,7 @@ "max_messages": ("model_params", "max_messages"), "max_tool_iterations": ("model_params", "max_tool_iterations"), "stream_timeout": ("model_params", "stream_timeout"), + "first_chunk_timeout": ("model_params", "first_chunk_timeout"), "model_filters": ("model_params", "model_filters"), "truncation_max_chars_small": ("model_params", "truncation_max_chars_small"), "truncation_max_chars_large": ("model_params", "truncation_max_chars_large"), @@ -59,6 +62,10 @@ "log_file": ("logging", "log_file"), "profiles_dir": ("paths", "profiles_dir"), "logs_dir": ("paths", "logs_dir"), + "default_profile": ("session", "default_profile"), + "session_auto_save": ("session", "auto_save"), + "session_resume_prompt": ("session", "resume_prompt"), + "workspace_max_workspaces": ("workspace", "max_workspaces"), "api_host": ("api_server", "host"), "api_port": ("api_server", "port"), "api_profile": ("api_server", "profile"), diff --git a/oli_bot/api/routers/ws.py b/oli_bot/api/routers/ws.py index 9338517..03dc95b 100644 --- a/oli_bot/api/routers/ws.py +++ b/oli_bot/api/routers/ws.py @@ -10,6 +10,7 @@ from ..attachments import attachment_note, save_attachments from ..convert import _event_to_frame from ..harness import _api_confirm +from ...activity import attach_turn_activity from ...models import Done, Message, UsageEvent from ..runner import _resolve_tools from ..session_service import ( @@ -46,6 +47,8 @@ async def _run_turn( tools = None total_tokens = 0 tokens_estimated = False + root_todos: List[dict] = [] + turn_start = len(messages) try: async for event in agent.process( messages, tools=tools, confirm_callback=_api_confirm @@ -53,6 +56,8 @@ async def _run_turn( await websocket.send_json(_event_to_frame(event)) while agent.mcp_manager.pending_todos: todo_data = agent.mcp_manager.pending_todos.pop(0) + if "task_id" not in todo_data: + root_todos = list(todo_data.get("todos") or []) await websocket.send_json( {"type": "todo", "data": todo_data} ) @@ -64,6 +69,7 @@ async def _run_turn( messages.append( Message(role="assistant", content=event.full_text) ) + attach_turn_activity(messages, turn_start, event.activity) if requested_session and store is not None: try: new_id = _persist_session( @@ -73,6 +79,7 @@ async def _run_turn( messages, total_tokens, tokens_estimated, + root_todos, ) if new_id != connection_session_id: connection_session_id = new_id diff --git a/oli_bot/api/session_service.py b/oli_bot/api/session_service.py index e48b4ff..6f62193 100644 --- a/oli_bot/api/session_service.py +++ b/oli_bot/api/session_service.py @@ -1,6 +1,6 @@ """Session persistence helpers shared by the WebSocket and session routes.""" -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from ..agent import sanitize_tool_history from ..models import Message @@ -37,6 +37,7 @@ def _persist_session( messages: List[Message], total_tokens: int, tokens_estimated: bool, + todos: Optional[List[dict]] = None, ) -> str: """Save a WebSocket conversation turn to the shared store. @@ -51,6 +52,7 @@ def _persist_session( profile=agent.profile_name or "", total_tokens=total_tokens, tokens_estimated=tokens_estimated, + todos=todos, ) diff --git a/oli_bot/chat.py b/oli_bot/chat.py index 7638fff..7b74577 100644 --- a/oli_bot/chat.py +++ b/oli_bot/chat.py @@ -52,6 +52,7 @@ ) from .backends import Message, create_model_backend +from .activity import attach_turn_activity from .agent import ( Agent, AgentPool, @@ -474,6 +475,7 @@ def __init__( self._sub_runs: list[SubAgentRun] = [] self._sub_tree_nodes: dict = {} self._sub_todo_nodes: dict[str, list] = {} # task_id -> list of TreeNode + self._root_todos: list[dict] = [] # last todo list written by the agent self._sub_tree_timer = None self._command_matches: list[str] = [] self._suggestion_index: int = 0 @@ -494,6 +496,7 @@ def __init__( self.current_session_id = data["id"] self.total_tokens = data.get("total_tokens", 0) or 0 self.tokens_estimated = bool(data.get("total_tokens_estimated", False)) + self._root_todos = list(data.get("todos") or []) if data.get("model"): self.backend.model = data["model"] loaded = True @@ -512,6 +515,7 @@ def __init__( self.tokens_estimated = bool( data.get("total_tokens_estimated", False) ) + self._root_todos = list(data.get("todos") or []) if data.get("model"): self.backend.model = data["model"] loaded = True @@ -715,6 +719,7 @@ def _save_session(self) -> None: profile=self.agent.profile_name or "", total_tokens=self.total_tokens, tokens_estimated=self.tokens_estimated, + todos=self._root_todos, ) if new_id != self.current_session_id: logger.info( @@ -1190,6 +1195,7 @@ def _sessions_switch(self, target: str) -> None: self.update_header() self.query_one("#chat-log").remove_children() self._clear_todo_widget() + self._restore_todos(data.get("todos")) self._add_message("System", f"Switched to session [bold]{data['name']}[/bold].") self._restore_history() @@ -2486,6 +2492,7 @@ def stop_spinner() -> None: _had_error: bool = False await start_spinner() + turn_start = len(self.messages) try: async for event in self.agent.process( self.messages, tools=tools, confirm_callback=self._permission_callback @@ -2632,7 +2639,7 @@ def stop_spinner() -> None: self.total_tokens += usage.total_tokens self.tokens_estimated = self.tokens_estimated or usage.estimated self.update_header() - case AgentDone(full_text): + case AgentDone(full_text, activity): stop_spinner() if think_widget is not None: think_widget.title = "💭 Thinking" @@ -2679,6 +2686,7 @@ def stop_spinner() -> None: "Skipping empty assistant message append " "(would poison next request under strict validators)" ) + attach_turn_activity(self.messages, turn_start, activity) # Auto-prune oldest messages when exceeding max_messages if len(self.messages) > self.config.max_messages: @@ -2763,6 +2771,7 @@ def _on_todos_changed(self, todos: list[dict]) -> None: This runs synchronously inside Textual's event loop (the @work task), so direct widget mutations are safe. """ + self._root_todos = list(todos) try: todo_state = self._builtin_tools.get_todos() todo_panel = self.query_one("#todo-panel", TodoWidget) @@ -2780,12 +2789,28 @@ def _on_todos_changed(self, todos: list[dict]) -> None: except Exception: logger.debug("Failed to update todo widget", exc_info=True) + def _restore_todos(self, todos: list[dict] | None) -> None: + """Restore a persisted todo list into the live widget state.""" + restored = list(todos or []) + self._root_todos = restored + self._builtin_tools._todos = list(restored) + if not restored: + return + try: + todo_panel = self.query_one("#todo-panel", TodoWidget) + todo_panel.update_todos(self._builtin_tools.get_todos()) + todo_panel.display = True + self.query_one("#right-panel").display = True + except Exception: + logger.debug("Failed to restore todo widget", exc_info=True) + def _clear_todo_widget(self) -> None: """Hide the todo panel and clear internal todo state. Called on chat clear and session switch so stale todos don't persist. """ self._builtin_tools._todos = [] + self._root_todos = [] self._sub_todo_nodes = {} try: todo_panel = self.query_one("#todo-panel", TodoWidget) diff --git a/oli_bot/models.py b/oli_bot/models.py index b51d203..3c5a2d4 100644 --- a/oli_bot/models.py +++ b/oli_bot/models.py @@ -29,6 +29,11 @@ class Message: timestamp: Optional[str] = None tool_call_id: Optional[str] = None images: Optional[List[ImageAttachment]] = None + # Serialized turn activity (thinking text, tool-call results and timings). + # Set by the API/TUI layer on the message that closes a turn so a + # reopened session can rebuild the same view the live UI showed. Never + # sent to the model — backends build their payload field by field. + activity: Optional[Dict[str, Any]] = None @dataclass @@ -126,6 +131,8 @@ class Error: @dataclass class Done: full_text: str + # Serialized activity for the turn that just finished, if any. + activity: Optional[Dict[str, Any]] = None @dataclass @@ -163,6 +170,8 @@ class SubAgentCompleted: agent_name: str status: str # "done" | "error" full_text: str + # Most recent todo list written by the sub-agent, if any. + todos: List[dict] = field(default_factory=list) @dataclass diff --git a/oli_bot/sessions.py b/oli_bot/sessions.py index 2651abc..9373dfa 100644 --- a/oli_bot/sessions.py +++ b/oli_bot/sessions.py @@ -269,6 +269,8 @@ def _message_to_dict(msg: Message) -> dict: d["timestamp"] = msg.timestamp if msg.tool_call_id is not None: d["tool_call_id"] = msg.tool_call_id + if msg.activity is not None: + d["activity"] = msg.activity if msg.images: logger.debug( "Dropping %d image attachment(s) from persisted message (role=%s)", @@ -286,6 +288,7 @@ def _message_from_dict(d: dict) -> Message: name=d.get("name"), timestamp=d.get("timestamp"), tool_call_id=d.get("tool_call_id"), + activity=d.get("activity"), ) @@ -355,6 +358,7 @@ def save_session( profile: str, total_tokens: int = 0, tokens_estimated: bool = False, + todos: Optional[List[dict]] = None, ) -> str: """Persist a session; return the (possibly new) session id. @@ -404,6 +408,8 @@ def save_session( tokens_estimated = False data["total_tokens"] = int(total_tokens or 0) data["total_tokens_estimated"] = bool(tokens_estimated) + if todos is not None: + data["todos"] = list(todos) try: path.write_text(json.dumps(data, indent=2), encoding="utf-8") except PermissionError as e: diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 78fcf8b..f81c7a9 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -806,6 +806,7 @@ def test_event_to_frame_sub_agent_lifecycle(): "agent_name": "analyst", "status": "done", "full_text": "result", + "todos": [], }, } diff --git a/tests/unit/test_api_sessions.py b/tests/unit/test_api_sessions.py index 8adfc1f..ab39cf2 100644 --- a/tests/unit/test_api_sessions.py +++ b/tests/unit/test_api_sessions.py @@ -16,7 +16,7 @@ from oli_bot.agent import Agent from oli_bot.config import AppConfig from oli_bot.mcp_client import MCPClientManager -from oli_bot.models import TextChunk +from oli_bot.models import TextChunk, ToolCall, ToolCallChunk from oli_bot.sessions import ConversationStore, Session from oli_bot.tools.manager import BuiltinToolManager @@ -29,6 +29,24 @@ async def stream_generate(self, messages, tools=None): yield TextChunk("from stub") +class _ToolStub: + """Backend that requests one builtin tool call, then answers.""" + + model = "stub-tool" + + def __init__(self): + self._calls = 0 + + async def stream_generate(self, messages, tools=None): + self._calls += 1 + if self._calls == 1: + yield ToolCallChunk( + [ToolCall(id="c1", name="builtin__echo", parameters={"x": 1})] + ) + else: + yield TextChunk("done after tool") + + class _HistoryProbeStub: """Counts the messages it sees so a test can assert persisted history.""" @@ -50,6 +68,20 @@ def _make_harness(backend) -> Agent: backend=backend, config=config, ) + + def _echo(x: int) -> str: + return f"echoed-{x}" + + builtin.register_tool( + name="echo", + description="Echo back the input.", + parameters={ + "type": "object", + "properties": {"x": {"type": "integer"}}, + }, + handler=_echo, + ) + mcp = MCPClientManager( builtin_tools=builtin, offline_mode=True, @@ -206,3 +238,47 @@ def test_websocket_clear_wipes_persisted_session(api): stored = api.client.get(f"/v1/sessions/{sid}").json() assert stored["messages"] == [] + + +def test_websocket_persists_turn_activity(api): + """A tool turn stores its activity so the browser can rebuild the view.""" + api.reset(_ToolStub()) + sid = api.client.post("/v1/sessions").json()["id"] + with api.client.websocket_connect("/v1/chat") as ws: + assert ws.receive_json()["type"] == "connected" + ws.send_json({"content": "use the tool", "session_id": sid}) + frames = _recv_until_done(ws) + assert frames[-1]["type"] == "done" + + stored = api.client.get(f"/v1/sessions/{sid}").json() + assistant = [m for m in stored["messages"] if m["role"] == "assistant"] + assert assistant, "expected an assistant message" + activity = assistant[-1].get("activity") + assert activity is not None + assert len(activity["tool_calls"]) == 1 + call = activity["tool_calls"][0] + assert call["name"] == "builtin__echo" + assert call["parameters"] == {"x": 1} + assert call["result"] == "echoed-1" + assert call["elapsed"] >= 0 + assert activity["elapsed"] >= 0 + + +def test_websocket_activity_does_not_pollute_the_model_payload(api): + """The stored activity must never be replayed to the model.""" + api.reset(_ToolStub()) + sid = api.client.post("/v1/sessions").json()["id"] + with api.client.websocket_connect("/v1/chat") as ws: + assert ws.receive_json()["type"] == "connected" + ws.send_json({"content": "first", "session_id": sid}) + _recv_until_done(ws) + ws.send_json({"content": "second", "session_id": sid}) + _recv_until_done(ws) + + stored = api.client.get(f"/v1/sessions/{sid}").json() + assert all( + "activity" not in m or m["role"] == "assistant" for m in stored["messages"] + ) + # The second turn's replayed history must not contain the activity blob. + replayed = json.dumps([m for m in stored["messages"] if m["role"] == "user"]) + assert "tool_calls" not in replayed diff --git a/tests/unit/test_config_api.py b/tests/unit/test_config_api.py new file mode 100644 index 0000000..4ecad0e --- /dev/null +++ b/tests/unit/test_config_api.py @@ -0,0 +1,113 @@ +"""Tests for the browser UI's flat ``/v1/config`` routes.""" + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import oli_bot.api.routers.config as config_router +from oli_bot.settings import DEFAULT_SETTINGS, SettingsManager + + +def _leaf_paths(node, prefix=()): + paths = set() + for key, value in node.items(): + path = prefix + (key,) + if isinstance(value, dict) and value: + paths |= _leaf_paths(value, path) + else: + paths.add(path) + return paths + + +def _mapped_paths(): + return { + (group, nested_key) if group else (nested_key,) + for group, nested_key in config_router._FLAT_TO_NESTED.values() + } + + +def test_flat_mapping_covers_every_default_setting(): + """Every settings.json leaf must be reachable from the browser, and every + flat key must point at a real leaf — so a new setting can't ship + invisible to ``GET/PUT /v1/config``.""" + defaults = _leaf_paths(DEFAULT_SETTINGS) + mapped = _mapped_paths() + assert mapped == defaults, f"unmapped: {defaults - mapped}; stale: {mapped - defaults}" + + +def test_flat_to_nested_and_back_round_trips(): + flat = config_router._nested_to_flat(DEFAULT_SETTINGS) + settings = config_router._flat_to_nested(flat, {}) + assert settings == DEFAULT_SETTINGS + + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setattr( + config_router, + "SettingsManager", + lambda: SettingsManager(config_dir=tmp_path), + ) + app = FastAPI() + app.include_router(config_router.router) + return TestClient(app) + + +def test_get_returns_session_workspace_and_backend_extras(client): + data = client.get("/v1/config").json() + assert data["openai_responses_enabled"] is False + assert data["ollama_context_window"] == 8192 + assert data["first_chunk_timeout"] == 600.0 + assert data["default_profile"] == "default" + assert data["session_auto_save"] is True + assert data["session_resume_prompt"] is True + assert data["workspace_max_workspaces"] == 20 + assert data["use_oauth"] is False + assert data["agents_yaml"] == "" + + +def test_put_persists_new_keys(tmp_path, client): + payload = { + "openai_responses_enabled": True, + "ollama_context_window": 16384, + "first_chunk_timeout": 120.0, + "default_profile": "researcher", + "session_auto_save": False, + "session_resume_prompt": False, + "workspace_max_workspaces": 5, + } + res = client.put("/v1/config", json=payload) + assert res.status_code == 200 + echoed = res.json() + for key, value in payload.items(): + assert echoed[key] == value + + saved = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8")) + assert saved["openai"]["responses_enabled"] is True + assert saved["ollama"]["context_window"] == 16384 + assert saved["model_params"]["first_chunk_timeout"] == 120.0 + assert saved["session"] == { + "auto_save": False, + "resume_prompt": False, + "default_profile": "researcher", + } + assert saved["workspace"] == {"max_workspaces": 5} + + fetched = client.get("/v1/config").json() + for key, value in payload.items(): + assert fetched[key] == value + + +def test_put_leaves_untouched_settings_alone(tmp_path, client): + settings = SettingsManager(config_dir=tmp_path).load() + settings["openai"]["api_key"] = "sk-secret" + settings["temperature"] = 0.42 + SettingsManager(config_dir=tmp_path).save(settings) + + client.put("/v1/config", json={"session_auto_save": False}) + + saved = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8")) + assert saved["openai"]["api_key"] == "sk-secret" + assert saved["temperature"] == 0.42 diff --git a/tests/unit/test_openai_responses_backend.py b/tests/unit/test_openai_responses_backend.py index b1682b6..d89386b 100644 --- a/tests/unit/test_openai_responses_backend.py +++ b/tests/unit/test_openai_responses_backend.py @@ -123,13 +123,13 @@ async def test_generate_uses_responses_and_parses_output(): ) b, dummy = _make_backend(responses=responses) messages = [_msg(role="user", content="hi")] - out = await b.generate(None, messages, tools=[], max_tokens=64) + out = await b.generate(None, messages, tools=[], max_tokens=64, temperature=0.3) assert dummy.calls[0]["model"] == "gpt-fake" assert dummy.calls[0]["input"] == [{"role": "user", "content": "hi"}] assert dummy.calls[0]["tool_choice"] == "none" assert dummy.calls[0]["max_output_tokens"] == 64 - assert dummy.calls[0]["temperature"] == pytest.approx(0.7) + assert dummy.calls[0]["temperature"] == pytest.approx(0.3) assert out.content == "hello world" assert len(out.tool_calls) == 1 assert out.tool_calls[0].id == "call_1" diff --git a/tests/unit/test_session_activity.py b/tests/unit/test_session_activity.py new file mode 100644 index 0000000..39d3afc --- /dev/null +++ b/tests/unit/test_session_activity.py @@ -0,0 +1,356 @@ +"""Tests for turn-activity capture and persistence. + +The browser rebuilds a reopened session from the persisted transcript. The +transcript alone only carries *what was said*, so the agent also records +*what happened* (model thinking, tool-call results and timings, dispatched +sub-agent runs) and the API layer attaches it to the message that closes the +turn. These tests cover the capture, the round-trip through the session file +and the WebSocket frame. +""" + +import json + +import pytest + +from oli_bot.activity import TurnActivity, attach_turn_activity +from oli_bot.agent import Agent +from oli_bot.api.convert import _event_to_frame +from oli_bot.backends.messages import _format_messages +from oli_bot.config import AppConfig +from oli_bot.models import ( + Done, + Message, + StreamChunk, + SubAgentCompleted, + SubAgentEvent, + SubAgentProgress, + SubAgentStarted, + TextChunk, + ThinkingChunk, + ToolCall, + ToolCallChunk, + ToolCallExecuting, + ToolCallResult, +) +from oli_bot.sessions import ConversationStore, _message_from_dict, _message_to_dict + +# --------------------------------------------------------------------------- # +# Capture # +# --------------------------------------------------------------------------- # + + +def test_activity_is_empty_when_nothing_happened(): + activity = TurnActivity() + assert activity.to_dict() is None + + +def test_activity_records_thinking_as_segments(): + activity = TurnActivity() + activity.add_thinking("first ") + activity.add_thinking("part") + activity.close_thinking_segment() + activity.add_thinking("second segment") + + data = activity.to_dict() + assert data is not None + assert data["thinking"] == "first part\n\nsecond segment" + + +def test_activity_records_tool_calls_with_results_and_timings(): + activity = TurnActivity() + activity.start_tool_call("call_0", "read_file", {"path": "a.py"}) + activity.finish_tool_call("call_0", "file contents", 0.25) + + data = activity.to_dict() + assert data is not None + assert data["tool_calls"] == [ + { + "id": "call_0", + "name": "read_file", + "parameters": {"path": "a.py"}, + "result": "file contents", + "elapsed": 0.25, + } + ] + + +def test_activity_collects_tool_calls_across_rounds(): + activity = TurnActivity() + activity.start_tool_call("call_0", "a", {}) + activity.finish_tool_call("call_0", "r0", 0.1) + activity.start_tool_call("call_0", "b", {}) + activity.finish_tool_call("call_0", "r1", 0.2) + + data = activity.to_dict() + assert data is not None + assert [call["name"] for call in data["tool_calls"]] == ["a", "b"] + assert [call["result"] for call in data["tool_calls"]] == ["r0", "r1"] + + +def test_activity_folds_sub_agent_events(): + activity = TurnActivity() + activity.observe( + SubAgentStarted( + task_id="run-1", + agent_name="researcher", + pool_name="default", + task="find X", + ) + ) + activity.observe( + SubAgentEvent( + task_id="run-1", agent_name="researcher", event=StreamChunk("par") + ) + ) + activity.observe( + SubAgentEvent( + task_id="run-1", agent_name="researcher", event=StreamChunk("tial") + ) + ) + activity.observe( + SubAgentEvent( + task_id="run-1", + agent_name="researcher", + event=ToolCallExecuting(name="read", parameters={"path": "x"}), + ) + ) + activity.observe( + SubAgentEvent( + task_id="run-1", + agent_name="researcher", + event=ToolCallResult(name="read", result="contents"), + ) + ) + activity.observe( + SubAgentProgress( + task_id="run-1", agent_name="researcher", activity="done", status="done" + ) + ) + activity.observe( + SubAgentCompleted( + task_id="run-1", + agent_name="researcher", + status="done", + full_text="all done", + todos=[{"content": "step", "status": "completed", "priority": "high"}], + ) + ) + + data = activity.to_dict() + assert data is not None + assert len(data["sub_agents"]) == 1 + run = data["sub_agents"][0] + assert run["task_id"] == "run-1" + assert run["agent_name"] == "researcher" + assert run["pool_name"] == "default" + assert run["task"] == "find X" + assert run["status"] == "done" + assert run["full_text"] == "all done" + assert run["text"] == "partial" + assert run["tool_calls"] == [ + {"name": "read", "parameters": {"path": "x"}, "result": "contents"} + ] + assert run["todos"] == [ + {"content": "step", "status": "completed", "priority": "high"} + ] + + +# --------------------------------------------------------------------------- # +# Placement # +# --------------------------------------------------------------------------- # + + +def test_attach_prefers_the_final_assistant_message(): + messages = [ + Message(role="user", content="hi"), + Message(role="assistant", content="calling", tool_calls=[{"id": "c1"}]), + Message(role="tool", content="ok", tool_call_id="c1"), + Message(role="assistant", content="done"), + ] + assert attach_turn_activity(messages, 1, {"thinking": "hm"}) is True + assert messages[3].activity == {"thinking": "hm"} + assert messages[2].activity is None + + +def test_attach_falls_back_to_the_last_tool_message(): + messages = [ + Message(role="user", content="hi"), + Message(role="assistant", content="calling", tool_calls=[{"id": "c1"}]), + Message(role="tool", content="ok", tool_call_id="c1"), + ] + assert attach_turn_activity(messages, 1, {"thinking": "hm"}) is True + assert messages[2].activity == {"thinking": "hm"} + + +def test_attach_does_not_cross_the_turn_boundary(): + messages = [ + Message(role="user", content="hi"), + Message(role="assistant", content="old"), + ] + assert attach_turn_activity(messages, 2, {"thinking": "hm"}) is False + assert messages[1].activity is None + + +def test_attach_ignores_empty_activity(): + messages = [Message(role="assistant", content="x")] + assert attach_turn_activity(messages, 0, None) is False + assert attach_turn_activity(messages, 0, {}) is False + assert messages[0].activity is None + + +# --------------------------------------------------------------------------- # +# Persistence # +# --------------------------------------------------------------------------- # + + +def test_activity_survives_the_message_round_trip(): + message = Message( + role="assistant", + content="done", + activity={"thinking": "hm", "elapsed": 1.5, "tool_calls": []}, + ) + restored = _message_from_dict(json.loads(json.dumps(_message_to_dict(message)))) + assert restored.activity == message.activity + + +def test_activity_is_omitted_when_absent(): + assert "activity" not in _message_to_dict(Message(role="user", content="hi")) + + +def test_session_file_persists_activity_and_todos(tmp_path): + store = ConversationStore(sessions_dir=tmp_path) + sid = store.create_session("srv", "m", "p", "") + messages = [ + Message(role="user", content="hi"), + Message( + role="assistant", + content="done", + activity={"thinking": "hm", "elapsed": 2.0, "tool_calls": []}, + ), + ] + todos = [{"content": "step", "status": "completed", "priority": "low"}] + store.save_session("srv", sid, messages, "m", "p", todos=todos) + + data = store.load_session("srv", sid) + assert data is not None + assert data["messages"][1]["activity"]["thinking"] == "hm" + assert data["todos"] == todos + + +def test_activity_never_reaches_the_model_payload(): + messages = [ + Message(role="user", content="hi"), + Message( + role="assistant", + content="done", + activity={"thinking": "secret reasoning", "tool_calls": []}, + ), + ] + formatted = _format_messages(messages) + assert all("activity" not in msg for msg in formatted) + assert "secret reasoning" not in json.dumps(formatted) + + +def test_done_frame_carries_the_activity(): + frame = _event_to_frame(Done(full_text="hi", activity={"thinking": "hm"})) + assert frame["type"] == "done" + assert frame["data"]["activity"] == {"thinking": "hm"} + + +def test_done_frame_without_activity_is_null(): + frame = _event_to_frame(Done(full_text="hi")) + assert frame["data"]["activity"] is None + + +def test_sub_agent_completed_frame_carries_the_todos(): + frame = _event_to_frame( + SubAgentCompleted( + task_id="run-1", + agent_name="researcher", + status="done", + full_text="all done", + todos=[{"content": "step", "status": "completed", "priority": "high"}], + ) + ) + assert frame["type"] == "sub_agent_completed" + assert frame["data"]["todos"] == [ + {"content": "step", "status": "completed", "priority": "high"} + ] + + +# --------------------------------------------------------------------------- # +# Agent turn capture # +# --------------------------------------------------------------------------- # + + +class _ToolStub: + """Backend that requests one tool call, then answers.""" + + model = "stub" + + def __init__(self): + self._calls = 0 + + async def stream_generate(self, messages, tools=None): + self._calls += 1 + if self._calls == 1: + yield ThinkingChunk("let me check") + yield TextChunk("checking") + yield ToolCallChunk( + [ToolCall(id="c1", name="read_file", parameters={"path": "a.py"})] + ) + else: + yield TextChunk("all good") + + +class _StubMCP: + async def call_tool(self, name, params, confirm_callback=None, **kwargs): + return "file contents" + + +def _agent(backend): + return Agent( + role="default", + backend=backend, + mcp_manager=_StubMCP(), + profile_name="default", + mode="agent", + config=AppConfig(_env_file=None, max_tool_iterations=3), + ) + + +async def _run(agent, messages): + return [event async for event in agent.process(messages)] + + +@pytest.mark.asyncio +async def test_tool_turn_done_carries_the_activity(): + agent = _agent(_ToolStub()) + messages = [Message(role="user", content="hi")] + events = await _run(agent, messages) + + done = [e for e in events if isinstance(e, Done)][-1] + assert done.activity is not None + assert done.activity["thinking"] == "let me check" + assert done.activity["elapsed"] >= 0 + assert len(done.activity["tool_calls"]) == 1 + call = done.activity["tool_calls"][0] + assert call["id"] == "c1" + assert call["name"] == "read_file" + assert call["parameters"] == {"path": "a.py"} + assert call["result"] == "file contents" + assert call["elapsed"] >= 0 + + +@pytest.mark.asyncio +async def test_text_only_turn_has_no_activity(): + class _TextOnly: + model = "stub" + + async def stream_generate(self, messages, tools=None): + yield TextChunk("hello") + + agent = _agent(_TextOnly()) + events = await _run(agent, [Message(role="user", content="hi")]) + done = [e for e in events if isinstance(e, Done)][-1] + assert done.activity is None