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>
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"""The relayed-command dispatcher: what runs, what asks first, what's refused."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_console import tools as tools_mod
|
||||
from bolt_console.tools import Tools
|
||||
|
||||
|
||||
class FakeUI:
|
||||
def __init__(self, answers=()):
|
||||
self.answers = list(answers)
|
||||
self.calls = []
|
||||
self.results = []
|
||||
self.questions = []
|
||||
|
||||
def tool_call(self, name, argument):
|
||||
self.calls.append((name, argument))
|
||||
|
||||
def tool_result(self, text, error=False):
|
||||
self.results.append((text, error))
|
||||
|
||||
def diff(self, old, new):
|
||||
self.calls.append(("diff", old, new))
|
||||
|
||||
def confirm(self, question):
|
||||
self.questions.append(question)
|
||||
return self.answers.pop(0) if self.answers else "no"
|
||||
|
||||
|
||||
class FakeShell:
|
||||
def __init__(self, output="[exit 0]\nhello"):
|
||||
self.output = output
|
||||
self.ran = []
|
||||
|
||||
def __call__(self, command):
|
||||
self.ran.append(command)
|
||||
return self.output
|
||||
|
||||
|
||||
def filectl(**payload) -> str:
|
||||
return "filectl " + json.dumps(payload)
|
||||
|
||||
|
||||
def test_shell_command_asks_first_and_runs_on_yes():
|
||||
ui, shell = FakeUI(["yes"]), FakeShell()
|
||||
output = Tools(ui, run_shell=shell).handle("echo hello")
|
||||
assert shell.ran == ["echo hello"]
|
||||
assert output == "[exit 0]\nhello"
|
||||
assert ui.calls == [("Shell", "echo hello")]
|
||||
assert ui.results == [("hello", False)]
|
||||
|
||||
|
||||
def test_declined_command_never_runs_and_tells_the_model_why():
|
||||
ui, shell = FakeUI(["no"]), FakeShell()
|
||||
output = Tools(ui, run_shell=shell).handle("rm -rf build")
|
||||
assert shell.ran == []
|
||||
assert "declined" in output and "did not run" in output
|
||||
|
||||
|
||||
def test_always_switches_the_session_to_auto():
|
||||
ui, shell = FakeUI(["always"]), FakeShell()
|
||||
tools = Tools(ui, run_shell=shell)
|
||||
tools.handle("echo one")
|
||||
tools.handle("echo two")
|
||||
assert tools.mode == "auto"
|
||||
assert len(ui.questions) == 1
|
||||
assert shell.ran == ["echo one", "echo two"]
|
||||
|
||||
|
||||
def test_auto_mode_never_asks():
|
||||
ui, shell = FakeUI(), FakeShell()
|
||||
Tools(ui, mode="auto", run_shell=shell).handle("echo hi")
|
||||
assert ui.questions == []
|
||||
assert shell.ran == ["echo hi"]
|
||||
|
||||
|
||||
def test_failed_command_is_shown_as_an_error_with_its_status():
|
||||
ui = FakeUI(["yes"])
|
||||
Tools(ui, run_shell=FakeShell("[exit 2]\nno such file")).handle("cat nope")
|
||||
assert ui.results == [("[exit 2]\nno such file", True)]
|
||||
|
||||
|
||||
def test_reads_and_lists_never_ask(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
|
||||
ui = FakeUI()
|
||||
tools = Tools(ui, run_shell=FakeShell())
|
||||
read = tools.handle(filectl(op="read", path=str(tmp_path / "a.txt")))
|
||||
listed = tools.handle(filectl(op="list", path=str(tmp_path)))
|
||||
assert ui.questions == []
|
||||
assert "one" in read and "two" in read
|
||||
assert "a.txt" in listed
|
||||
assert ui.calls[0] == ("Read", str(tmp_path / "a.txt"))
|
||||
assert ui.results[0] == ("Read 2 lines", False)
|
||||
|
||||
|
||||
def test_write_asks_and_declining_leaves_the_disk_alone(tmp_path):
|
||||
target = tmp_path / "new.txt"
|
||||
ui = FakeUI(["no"])
|
||||
output = Tools(ui).handle(filectl(op="write", path=str(target), content="x"))
|
||||
assert not target.exists()
|
||||
assert "declined" in output
|
||||
|
||||
|
||||
def test_edit_shows_the_diff_then_applies_on_yes(tmp_path):
|
||||
target = tmp_path / "f.py"
|
||||
target.write_text("x = 1\n", encoding="utf-8")
|
||||
ui = FakeUI(["yes"])
|
||||
output = Tools(ui).handle(filectl(op="edit", path=str(target), old="x = 1", new="x = 2"))
|
||||
assert target.read_text(encoding="utf-8") == "x = 2\n"
|
||||
assert ("diff", "x = 1", "x = 2") in ui.calls
|
||||
assert "edited" in output
|
||||
|
||||
|
||||
def test_filectl_errors_go_back_to_the_model_not_up_the_stack(tmp_path):
|
||||
ui = FakeUI()
|
||||
output = Tools(ui).handle(filectl(op="read", path=str(tmp_path / "missing.txt")))
|
||||
assert output.startswith("[filectl] no such file")
|
||||
assert ui.results[-1][1] is True
|
||||
|
||||
|
||||
def test_pet_commands_are_explained_rather_than_run():
|
||||
ui, shell = FakeUI(), FakeShell()
|
||||
output = Tools(ui, mode="auto", run_shell=shell).handle("petctl emote wave")
|
||||
assert shell.ran == []
|
||||
assert output == tools_mod.PET_UNAVAILABLE.format(name="petctl")
|
||||
|
||||
|
||||
def test_dialogue_scenes_are_explained_rather_than_run():
|
||||
shell = FakeShell()
|
||||
output = Tools(FakeUI(), mode="auto", run_shell=shell).handle('dialoguectl {"lines": []}')
|
||||
assert shell.ran == []
|
||||
assert "isn't available" in output
|
||||
|
||||
|
||||
def test_a_stray_command_label_is_stripped_and_reported():
|
||||
"""Seen live: the server relayed `command: powershell -Command "..."` and
|
||||
cmd.exe answered "'command:' is not recognized"."""
|
||||
ui, shell = FakeUI(["yes"]), FakeShell()
|
||||
output = Tools(ui, run_shell=shell).handle('command: powershell -Command "Get-PnpDevice"')
|
||||
assert shell.ran == ['powershell -Command "Get-PnpDevice"']
|
||||
assert ui.calls[0] == ("Shell", 'powershell -Command "Get-PnpDevice"')
|
||||
assert 'removed a leading "command:" label' in output
|
||||
|
||||
|
||||
def test_wrapping_backticks_are_stripped():
|
||||
shell = FakeShell()
|
||||
Tools(FakeUI(), mode="auto", run_shell=shell).handle("`git status`")
|
||||
assert shell.ran == ["git status"]
|
||||
|
||||
|
||||
def test_the_label_is_stripped_before_filectl_is_recognised(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hi", encoding="utf-8")
|
||||
output = Tools(FakeUI()).handle(
|
||||
"command: filectl " + json.dumps({"op": "read", "path": str(tmp_path / "a.txt")}))
|
||||
assert "hi" in output and "command:" in output # read ran; note explains the fix
|
||||
|
||||
|
||||
def test_a_clean_command_gets_no_note():
|
||||
output = Tools(FakeUI(), mode="auto", run_shell=FakeShell()).handle("echo hi")
|
||||
assert "note" not in output
|
||||
Reference in New Issue
Block a user