Files
Bolt-Console/tests/test_console.py
T
themajesticmagicianandClaude Opus 5.5 3977804ae3 Bolt Console: desktop app with chat list / new chat, dictation, file tools
Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:

- server_client.list_chats() / create_chat(), same shape and error
  handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
  SESSION_ID every call reads, persisted to session.json. The window
  refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
  populated on open, with Refresh and New Chat. Network calls run as
  QRunnables on the global QThreadPool and come back through Signals,
  never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
  past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
  fallback).

This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.

Build output (build/, dist/, Release/, *.spec) is now ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:50:10 -06:00

294 lines
11 KiB
Python

"""Whole turns over real HTTP: the real server_client, the real Tools and the
real Terminal (writing to a buffer), against a threaded server on a loopback
port that speaks the desk protocol. Only the keyboard is faked."""
import io
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_console import config, console
from bolt_console.console import Session
from bolt_console.tools import Tools
from bolt_console.ui import Terminal
class FakeDesk:
"""Scripted responses, real HTTP. Set `.script` per test."""
def __init__(self):
self.script = {}
self.requests = []
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
threading.Thread(target=self._server.serve_forever, daemon=True).start()
@property
def url(self) -> str:
host, port = self._server.server_address[:2]
return f"http://{host}:{port}"
def stop(self) -> None:
self._server.shutdown()
self._server.server_close()
def _handler(self):
desk = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def do_GET(self):
path = self.path.split("?")[0]
desk.requests.append(("GET", path))
response = desk.script.get(path, {"files": []})
if isinstance(response, bytes):
self.send_response(200)
self.send_header("Content-Length", str(len(response)))
self.end_headers()
self.wfile.write(response)
else:
self._json(response)
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(length) or b"{}")
path = self.path.split("?")[0]
desk.requests.append(("POST", path, body))
response = desk.script.get(path)
if callable(response):
response = response(body)
if path.endswith("converse_stream"):
if response is None:
self.send_response(404)
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Type", "application/x-ndjson")
self.end_headers()
for event in response:
self.wfile.write((json.dumps(event) + "\n").encode())
self.wfile.flush()
else:
self._json(response if response is not None else {"type": "reply", "text": "ok"})
def _json(self, payload):
data = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return Handler
@pytest.fixture
def desk(monkeypatch, tmp_path):
server = FakeDesk()
monkeypatch.setattr(config, "SERVER_URL", server.url)
monkeypatch.setattr(config, "API_KEY", "k")
monkeypatch.setattr(config, "SESSION_ID", "pet-console-test")
monkeypatch.setattr(config, "DELIVERED_FILES_DIR", tmp_path / "delivered")
monkeypatch.chdir(tmp_path)
yield server
server.stop()
def make_session(answers=(), mode="ask"):
answers = list(answers)
out = io.StringIO()
ui = Terminal(out=out, color=False, input_fn=lambda _p: answers.pop(0), animate=False)
return Session(ui, tools=Tools(ui, mode=mode)), out
def converse_bodies(desk):
return [r[2] for r in desk.requests if r[0] == "POST" and "converse" in r[1]]
def test_a_plain_reply_is_printed_once(desk):
desk.script["/desk/converse"] = {"type": "reply", "text": "Hi there."}
session, out = make_session()
session.handle("hello")
assert out.getvalue().count("Hi there.") == 1
def test_streamed_sentences_print_as_one_block_and_are_not_repeated(desk):
desk.script["/desk/converse_stream"] = [
{"type": "say", "text": "First."},
{"type": "say", "text": "Second."},
{"type": "reply", "text": "First. Second.", "already_spoken": True},
]
session, out = make_session()
session.handle("hello")
text = out.getvalue()
assert text.count("First.") == 1 and text.count("Second.") == 1
assert "* First.\n Second." in text
def test_the_message_carries_the_working_directory(desk, tmp_path):
session, _ = make_session()
session.handle("what's here?")
body = converse_bodies(desk)[-1]
assert body["session_id"] == "pet-console-test"
assert body["text"].startswith("what's here?")
assert str(tmp_path) in body["text"]
def test_an_approved_edit_lands_on_disk_and_the_result_goes_back_up(desk, tmp_path):
target = tmp_path / "app.py"
target.write_text("DEBUG = True\n", encoding="utf-8")
edit = "filectl " + json.dumps(
{"op": "edit", "path": str(target), "old": "DEBUG = True", "new": "DEBUG = False"})
desk.script["/desk/converse"] = {"type": "command", "command": edit, "token": "t1"}
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Turned debug off."}
session, out = make_session(answers=["y"])
session.handle("turn debug off in app.py")
assert target.read_text(encoding="utf-8") == "DEBUG = False\n"
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
assert posted[0]["token"] == "t1" and "edited" in posted[0]["output"]
text = out.getvalue()
assert "Edit(" in text and "- DEBUG = True" in text and "+ DEBUG = False" in text
assert "Turned debug off." in text
def test_a_declined_shell_command_reports_back_and_the_turn_still_ends(desk, tmp_path):
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi > made.txt", "token": "t"}
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Okay, I won't."}
session, out = make_session(answers=["n"])
session.handle("make a file")
assert not (tmp_path / "made.txt").exists()
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
assert "declined" in posted[0]["output"]
assert "Okay, I won't." in out.getvalue()
def test_delivered_files_are_saved_after_the_turn(desk, tmp_path):
desk.script["/desk/files"] = {"files": [{"id": "f1", "name": "report.txt", "size": 5}]}
desk.script["/desk/files/f1"] = b"hello"
session, out = make_session()
session.handle("send me the report")
assert (tmp_path / "delivered" / "report.txt").read_bytes() == b"hello"
assert "Received report.txt" in out.getvalue()
def test_server_errors_are_shown_not_raised(desk):
desk.script["/desk/converse"] = {"type": "error", "error": "unauthorized"}
session, out = make_session()
session.handle("hi")
assert "Error: unauthorized" in out.getvalue()
def test_slash_commands(desk, tmp_path):
(tmp_path / "sub").mkdir()
session, out = make_session()
assert session.handle("/cd sub") is True
assert Path.cwd() == tmp_path / "sub"
session.handle("/auto")
assert session.tools.mode == "auto"
session.handle("/ask")
assert session.tools.mode == "ask"
assert session.handle("/exit") is False
assert converse_bodies(desk) == [] # none of that went to Bolt
def test_loop_exits_cleanly_on_eof(desk):
out = io.StringIO()
def no_more_input(_prompt):
raise EOFError
ui = Terminal(out=out, color=False, input_fn=no_more_input, animate=False)
assert Session(ui).loop() == 0
assert "Bolt Console" in out.getvalue()
def test_backslash_continues_a_message_onto_the_next_line(desk):
lines = ["first line \\", "second line"]
ui = Terminal(out=io.StringIO(), color=False, input_fn=lambda _p: lines.pop(0), animate=False)
assert ui.read_message() == "first line \nsecond line"
def test_annotate_names_the_directory_and_says_there_is_no_pet():
note = console.annotate("hi", "/work", "Linux 6.1")
assert note.startswith("hi\n\n")
assert "/work" in note and "Linux 6.1" in note and "no pet body" in note
def test_a_reply_that_invents_command_results_is_flagged_and_corrected(desk):
"""Seen live: 'wrote 1200 chars to …' three turns running, no command sent,
no file on disk."""
desk.script["/desk/converse_stream"] = [
{"type": "say", "text": "Let me check that."},
{"type": "say", "text": "→ Command result: [filectl] wrote 1200 chars to C:/x/main.py"},
{"type": "reply", "text": "", "already_spoken": True},
]
session, out = make_session()
session.handle("write it")
assert console.FABRICATION_WARNING in out.getvalue()
desk.script["/desk/converse_stream"] = [{"type": "reply", "text": "Sorry.", "already_spoken": False}]
session.handle("did you write it?")
corrected = converse_bodies(desk)[-1]["text"]
assert corrected.startswith(console.CORRECTION)
assert "did you write it?" in corrected
session.handle("thanks") # the correction goes out once, not forever
assert not converse_bodies(desk)[-1]["text"].startswith(console.CORRECTION)
def test_real_command_output_quoted_in_a_reply_is_not_flagged(desk, tmp_path):
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi", "token": "t"}
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Got [exit 0] and hi."}
session, out = make_session(answers=["y"])
session.handle("say hi")
assert console.FABRICATION_WARNING not in out.getvalue()
def test_fabricated_matches_the_shapes_seen_live():
assert console.fabricated("→ Command result: [filectl] {...}", 0)
assert console.fabricated("-> Deliver files result: Files delivered to C:/x", 0)
assert console.fabricated("[filectl] wrote 1200 chars to C:/x", 0)
assert not console.fabricated("I wrote the plan below.", 0)
assert not console.fabricated("→ Command result: [filectl] wrote 3 chars", 1)
def test_the_context_note_spells_out_the_command_marker():
"""'use filectl and shell commands' made the live model write filectl as
prose; the explicit `command:` form got real relayed commands."""
note = console.annotate("hi", "/work", "Windows 10")
assert "`command: filectl {" in note
assert "`command: <shell command>`" in note
assert "Never write a command's output yourself" in note
def test_new_conversation_switches_and_remembers_the_session(desk, tmp_path, monkeypatch):
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "state" / "session.json")
monkeypatch.delenv("DESK_SESSION_ID", raising=False)
session, out = make_session()
session._correct_next = True
session.handle("/new")
new_id = config.SESSION_ID
assert new_id.startswith("pet-console-") and new_id != "pet-console-test"
assert new_id in out.getvalue()
assert not session._correct_next # nothing to correct in an empty history
assert config._remembered_session() == new_id # survives a restart
session.handle("hello")
assert converse_bodies(desk)[-1]["session_id"] == new_id
def test_a_corrupt_state_file_means_the_default_session(tmp_path, monkeypatch):
state = tmp_path / "session.json"
state.write_text("{not json", encoding="utf-8")
monkeypatch.setattr(config, "STATE_FILE", state)
assert config._remembered_session() == ""