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>
294 lines
11 KiB
Python
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() == ""
|