ソースを参照

Updated history behavior

Efren Yevale Varela 1 ヶ月 前
親
コミット
d452c1fc41

+ 7 - 0
src/stt_daemon/assistant/control.py

@@ -200,10 +200,17 @@ class ControlServer:
             self._drop(client)
 
     def _drop(self, client: _Client) -> None:
+        last_viewer = False
         with self._lock:
             if client in self._clients:
                 self._clients.remove(client)
+                # Losing the last viewer ends an unattended chat. Decide under the
+                # lock, then notify after releasing it: viewer_detached() emits,
+                # and emitting re-enters this non-reentrant lock.
+                last_viewer = client.viewer and not any(c.viewer for c in self._clients)
         self._shutdown(client)
+        if last_viewer:
+            self.service.viewer_detached()
 
     def _shutdown(self, client: _Client) -> None:
         try:

+ 7 - 0
src/stt_daemon/assistant/llm.py

@@ -126,18 +126,25 @@ class ChatSession:
         self.client = client
         self.system = cfg.system_prompt_chat
         self.history: list[dict] = []
+        self._generation = 0
 
     def reset(self) -> None:
         """Forget the conversation (volatile by design)."""
         self.history = []
+        self._generation += 1
 
     def send(self, user_text: str) -> str:
         """Append a user turn, ask the model, append and return the reply."""
+        generation = self._generation
         self.history.append({"role": "user", "content": user_text})
         self._trim()
         messages = [{"role": "system", "content": self.system}] + self.history
         response = self.client.chat(messages)
         reply = response.content
+        if generation != self._generation:
+            # The session was reset while the model was thinking: the reply belongs
+            # to a conversation that no longer exists, so drop it.
+            return ""
         self.history.append({"role": "assistant", "content": reply})
         return reply
 

+ 11 - 0
src/stt_daemon/assistant/service.py

@@ -310,6 +310,17 @@ class AssistantService:
         self._emit_mode()
         return True
 
+    def viewer_detached(self) -> bool:
+        """Leave chat when the last viewer goes away.
+
+        Chat only exists to be read and driven by an attached viewer, so losing
+        the last one ends the conversation (which clears its history). A no-op in
+        every other mode, where there is nothing to clear.
+        """
+        if self.modes.mode is not Mode.CHAT:
+            return False
+        return self._exit_chat("viewer_left")
+
     # ------------------------------------------------------------------ events
 
     def handle_event(self, event: dict) -> None:

+ 5 - 0
src/stt_daemon/tui/app.py

@@ -44,6 +44,7 @@ class AssistantTUI(App):
         self._events: queue.Queue = queue.Queue()
         self._seen_transcripts = 0
         self._seen_lower = 0
+        self._seen_epoch = 0
 
     def compose(self) -> ComposeResult:
         yield Static(id="status")
@@ -109,6 +110,10 @@ class AssistantTUI(App):
             self._seen_transcripts += 1
 
         lower_log = self.query_one("#chat", RichLog)
+        if self._seen_epoch != state.chat_epoch:
+            lower_log.clear()
+            self._seen_lower = 0
+            self._seen_epoch = state.chat_epoch
         lower_lines = state.lower_lines()
         while self._seen_lower < len(lower_lines):
             lower_log.write(lower_lines[self._seen_lower])

+ 22 - 8
src/stt_daemon/tui/state.py

@@ -36,19 +36,20 @@ class ViewState:
     in_flight: bool = False
     transcripts: list[dict] = field(default_factory=list)
     chat: list[dict] = field(default_factory=list)
-    lower: list[str] = field(default_factory=list)
+    lower: list[tuple[str, bool]] = field(default_factory=list)
     tool: dict | None = None
     tool_result: dict | None = None
     last_error: dict | None = None
+    chat_epoch: int = 0
 
     def ingest(self, event: dict) -> None:
         """Fold one event into the view state."""
         etype = event.get("type")
 
         if etype == "mode":
-            self.mode = event.get("mode", self.mode)
+            self._set_mode(event.get("mode", self.mode))
         elif etype == "status":
-            self.mode = event.get("mode", self.mode)
+            self._set_mode(event.get("mode", self.mode))
             self.ready = bool(event.get("ready", self.ready))
             self.phase = event.get("phase", self.phase)
             self.command_text = event.get("command_text", self.command_text)
@@ -61,7 +62,7 @@ class ViewState:
             self.speech_open = False
         elif etype == "heartbeat":
             # The heartbeat re-states the mode: a missed `mode` event self-corrects.
-            self.mode = event.get("mode", self.mode)
+            self._set_mode(event.get("mode", self.mode))
             self.stt_ok = bool(event.get("stt_ok", self.stt_ok))
             self.in_flight = bool(event.get("in_flight", self.in_flight))
             self.worker_stall_s = float(event.get("worker_stall_s", self.worker_stall_s) or 0.0)
@@ -79,7 +80,9 @@ class ViewState:
         elif etype == "chat_message":
             self.chat.append(event)
             del self.chat[:-MAX_CHAT]
-            self.append_lower(f"{event.get('role', '?')}: {event.get('text', '')}")
+            self.append_lower(
+                f"{event.get('role', '?')}: {event.get('text', '')}", from_chat=True
+            )
         elif etype == "tool_call":
             self.tool = event
             args = event.get("args") or {}
@@ -93,13 +96,24 @@ class ViewState:
             self.last_error = event
             self.append_lower(f"! {event.get('code')}: {event.get('message')}")
 
-    def append_lower(self, line: str) -> None:
+    def append_lower(self, line: str, from_chat: bool = False) -> None:
         """Append one line to the lower pane log (chat turns, tool calls, results, errors)."""
-        self.lower.append(line)
+        self.lower.append((line, from_chat))
         del self.lower[:-MAX_LOWER]
 
+    def clear_chat(self) -> None:
+        """Forget chat turns and their rendered lines, keeping tool/error output."""
+        self.chat = []
+        self.lower = [(line, from_chat) for line, from_chat in self.lower if not from_chat]
+        self.chat_epoch += 1
+
+    def _set_mode(self, mode: str) -> None:
+        if self.mode == "chat" and mode != "chat":
+            self.clear_chat()
+        self.mode = mode
+
     def lower_lines(self) -> list[str]:
-        return list(self.lower)
+        return [line for line, _ in self.lower]
 
     def health_ok(self) -> bool:
         """False when the header should go red."""

+ 29 - 0
tests/test_assistant_service.py

@@ -225,6 +225,35 @@ def test_chat_exit_phrase_exits_chat():
     assert service.chat_history == []
 
 
+def test_viewer_detached_exits_chat_and_forgets_history():
+    service, _, clock = _service()
+    assert service.toggle_chat() is True
+    service.handle_event(_transcript(clock, "hello"))
+
+    assert service.viewer_detached() is True
+    assert service.modes.mode.value == "idle"
+    assert service.chat_history == []
+
+
+def test_viewer_detached_is_noop_outside_chat():
+    service, _, _ = _service()
+    assert service.viewer_detached() is False
+    assert service.modes.mode.value == "idle"
+
+
+def test_exit_chat_invokes_chat_reset():
+    cfg = Config()
+    resets: list[int] = []
+    service = AssistantService(
+        cfg, FakeSubscriber(), time_fn=Clock(), chat_reset=lambda: resets.append(1)
+    )
+    service.toggle_chat()
+
+    service.viewer_detached()
+
+    assert resets == [1]
+
+
 def test_chat_accumulates_turns():
     service, _, clock = _service()
     service.toggle_chat()

+ 36 - 0
tests/test_control.py

@@ -229,6 +229,42 @@ def test_chat_toggle_broadcasts_mode(harness):
     client.close()
 
 
+def test_last_viewer_disconnect_ends_chat(harness):
+    client = Client(harness.path)
+    client.send(type="subscribe", viewer=True)
+    assert client.wait_for(lambda m: m["type"] == "status") is not None
+
+    client.send(type="chat_toggle")
+    assert client.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
+    assert harness.service.modes.mode.value == "chat"
+
+    client.close()
+
+    deadline = time.time() + 5.0
+    while time.time() < deadline and harness.service.modes.mode.value == "chat":
+        time.sleep(0.02)
+    assert harness.service.modes.mode.value == "idle"
+
+
+def test_non_last_viewer_disconnect_keeps_chat(harness):
+    first = Client(harness.path)
+    first.send(type="subscribe", viewer=True)
+    assert first.wait_for(lambda m: m["type"] == "status") is not None
+
+    second = Client(harness.path)
+    second.send(type="subscribe", viewer=True)
+    assert second.wait_for(lambda m: m["type"] == "status") is not None
+
+    first.send(type="chat_toggle")
+    assert first.wait_for(lambda m: m["type"] == "mode" and m["mode"] == "chat")
+
+    second.close()
+    time.sleep(0.4)
+    assert harness.service.modes.mode.value == "chat"
+
+    first.close()
+
+
 def test_status_reply_contains_fields(harness):
     client = Client(harness.path)
     client.send(type="status")

+ 13 - 0
tests/test_llm.py

@@ -119,3 +119,16 @@ def test_chat_session_reset_forgets_history():
     session.send("hi")
     session.reset()
     assert session.history == []
+
+
+def test_chat_session_drops_reply_when_reset_mid_flight():
+    holder: list[ChatSession] = []
+
+    def opener(_payload, _timeout):
+        holder[0].reset()
+        return _reply(content="late reply")
+
+    holder.append(ChatSession(LlmConfig(), LlmClient(LlmConfig(), opener=opener)))
+
+    assert holder[0].send("hi") == ""
+    assert holder[0].history == []

+ 31 - 0
tests/test_tui_app.py

@@ -96,6 +96,37 @@ def test_tui_renders_command_output(server):
     assert lines, "command output was not rendered anywhere"
 
 
+def test_tui_clears_chat_pane_on_chat_exit(server):
+    """Leaving chat must drop the rendered conversation but keep command output."""
+    pytest.importorskip("textual")
+    from textual.widgets import RichLog
+
+    from stt_daemon.tui.app import AssistantTUI
+
+    path, _service = server
+    app = AssistantTUI(path)
+
+    async def scenario():
+        async with app.run_test() as pilot:
+            await pilot.pause()
+            app.state.ingest(
+                {"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "kept"}
+            )
+            app.state.ingest({"type": "mode", "mode": "chat"})
+            app.state.ingest({"type": "chat_message", "role": "user", "text": "gone"})
+            app._render()
+            await pilot.pause()
+
+            app.state.ingest({"type": "mode", "mode": "idle"})
+            app._render()
+            await pilot.pause()
+            return [str(line) for line in app.query_one("#chat", RichLog).lines]
+
+    lines = asyncio.run(scenario())
+    assert not any("gone" in line for line in lines)
+    assert any("kept" in line for line in lines)
+
+
 def test_tui_mounts_subscribes_and_ingests_broadcasts(server):
     pytest.importorskip("textual")
     from stt_daemon.tui.app import AssistantTUI

+ 38 - 0
tests/test_tui_state.py

@@ -144,3 +144,41 @@ def test_chat_and_tool_events_recorded():
     assert state.tool is not None and state.tool["tool"] == "get_datetime"
     assert state.tool_result is not None and state.tool_result["ok"] is True
     assert state.last_error is not None and state.last_error["code"] == "no_viewer"
+
+
+def test_leaving_chat_clears_chat_pane_but_keeps_command_output():
+    state = ViewState()
+    state.ingest({"type": "tool_call", "tool": "get_datetime", "args": {}})
+    state.ingest({"type": "mode", "mode": "chat"})
+    state.ingest({"type": "chat_message", "role": "user", "text": "hello"})
+    state.ingest({"type": "chat_message", "role": "assistant", "text": "hi"})
+    assert state.chat_lines() == ["user: hello", "assistant: hi"]
+
+    epoch = state.chat_epoch
+    state.ingest({"type": "mode", "mode": "idle"})
+
+    assert state.chat_lines() == []
+    assert state.lower_lines() == ["-> tool get_datetime"]
+    assert state.chat_epoch == epoch + 1
+
+
+def test_heartbeat_mode_change_clears_chat():
+    state = ViewState()
+    state.ingest({"type": "mode", "mode": "chat"})
+    state.ingest({"type": "chat_message", "role": "user", "text": "x"})
+
+    state.ingest({"type": "heartbeat", "mode": "idle", "stt_ok": True})
+
+    assert state.chat_lines() == []
+    assert state.lower_lines() == []
+
+
+def test_entering_chat_does_not_clear_rendered_log():
+    state = ViewState()
+    state.ingest({"type": "tool_result", "tool": "get_datetime", "ok": True, "result": "x"})
+    epoch = state.chat_epoch
+
+    state.ingest({"type": "mode", "mode": "chat"})
+
+    assert state.chat_epoch == epoch
+    assert any("ok:" in line for line in state.lower_lines())