Merge branch 'main' into bb/gui

This commit is contained in:
emozilla
2026-05-20 16:01:41 -04:00
72 changed files with 2726 additions and 742 deletions
+138
View File
@@ -509,3 +509,141 @@ class TestParseErrorSignalling:
ops, err = parse_v4a_patch(patch)
assert err is None
assert len(ops) == 1
class TestV4ALspDiagnosticsPropagation:
"""V4A patches must surface ``WriteResult.lsp_diagnostics`` from the
underlying ``write_file`` calls on ``PatchResult.lsp_diagnostics``.
Without explicit propagation the LSP tier's output gets silently
dropped on the V4A code path — see Copilot review #3271017295 on
PR #29054. The shell-linter LSP skip introduced by that PR makes
this gap visible: a ``.ts`` / ``.go`` / ``.rs`` V4A patch with LSP
active would otherwise return ``lint = {f: {skipped: True, ...}}``
and zero diagnostics from any channel.
"""
def _build_ops_writing(self, path: str, content: str):
"""Build a single ADD operation that writes ``content`` to ``path``."""
# Use the V4A parser so we don't have to construct PatchOperation
# / Hunk / Line objects by hand.
lines = "\n".join(f"+{line}" for line in content.splitlines())
patch_text = (
"*** Begin Patch\n"
f"*** Add File: {path}\n"
f"{lines}\n"
"*** End Patch"
)
ops, err = parse_v4a_patch(patch_text)
assert err is None, err
return ops
def test_lsp_diagnostics_propagated_from_write_file_on_add(self):
"""ADD op: ``WriteResult.lsp_diagnostics`` flows through to
``PatchResult.lsp_diagnostics``."""
ops = self._build_ops_writing("foo.ts", "const x: number = 1\n")
diag_block = (
"<diagnostics file=\"foo.ts\">\n"
"ERROR [1:7] some diagnostic\n"
"</diagnostics>"
)
class FakeFileOps:
def write_file(self, path, content):
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"skipped": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics == diag_block
def test_lsp_diagnostics_propagated_from_write_file_on_update(self):
"""UPDATE op: ``WriteResult.lsp_diagnostics`` flows through to
``PatchResult.lsp_diagnostics``."""
patch_text = (
"*** Begin Patch\n"
"*** Update File: bar.ts\n"
"-old\n"
"+new\n"
"*** End Patch"
)
ops, err = parse_v4a_patch(patch_text)
assert err is None
diag_block = (
"<diagnostics file=\"bar.ts\">\n"
"ERROR [3:1] something\n"
"</diagnostics>"
)
class FakeFileOps:
def read_file_raw(self, path):
return SimpleNamespace(content="ctx\nold\nctx\n", error=None)
def write_file(self, path, content):
return SimpleNamespace(error=None, lsp_diagnostics=diag_block)
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"skipped": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics == diag_block
def test_lsp_diagnostics_none_when_no_blocks_emitted(self):
"""When no underlying ``write_file`` produced diagnostics, the
aggregated field stays ``None`` (so it doesn't get serialized
as an empty string in ``PatchResult.to_dict``)."""
ops = self._build_ops_writing("foo.py", "x = 1\n")
class FakeFileOps:
def write_file(self, path, content):
# lsp_diagnostics omitted entirely (older WriteResult shape).
return SimpleNamespace(error=None)
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"success": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics is None
def test_lsp_diagnostics_combined_across_multiple_files(self):
"""When several files in one V4A patch produce diagnostics,
each block appears in the combined output so per-file attribution
is preserved."""
patch_text = (
"*** Begin Patch\n"
"*** Add File: a.ts\n"
"+const a = 1\n"
"*** Add File: b.ts\n"
"+const b = 2\n"
"*** End Patch"
)
ops, err = parse_v4a_patch(patch_text)
assert err is None
per_file = {
"a.ts": "<diagnostics file=\"a.ts\">\nERR a\n</diagnostics>",
"b.ts": "<diagnostics file=\"b.ts\">\nERR b\n</diagnostics>",
}
class FakeFileOps:
def write_file(self, path, content):
return SimpleNamespace(error=None, lsp_diagnostics=per_file[path])
def _check_lint(self, path):
return SimpleNamespace(to_dict=lambda: {"skipped": True})
result = apply_v4a_operations(ops, FakeFileOps())
assert result.success is True
assert result.lsp_diagnostics is not None
assert per_file["a.ts"] in result.lsp_diagnostics
assert per_file["b.ts"] in result.lsp_diagnostics
+81
View File
@@ -0,0 +1,81 @@
"""Tests for xAI TTS speech-tag handling."""
from unittest.mock import Mock
from tools.tts_tool import _apply_xai_auto_speech_tags, _generate_xai_tts
def test_apply_xai_auto_speech_tags_adds_light_pause_after_first_sentence():
text = "Bonjour Monsieur Talbot. Ceci est un test de réponse vocale."
assert _apply_xai_auto_speech_tags(text) == (
"Bonjour Monsieur Talbot. [pause] Ceci est un test de réponse vocale."
)
def test_apply_xai_auto_speech_tags_preserves_explicit_tags():
text = "Bonjour. [pause] <whisper>Déjà balisé.</whisper>"
assert _apply_xai_auto_speech_tags(text) == text
def test_apply_xai_auto_speech_tags_preserves_all_documented_xai_tags():
text = "Bonjour Monsieur Talbot. [sigh] <slow>Je parle lentement.</slow> <emphasis>Important.</emphasis>"
assert _apply_xai_auto_speech_tags(text) == text
def test_generate_xai_tts_sends_auto_speech_tags_when_enabled(tmp_path, monkeypatch):
captured = {}
class FakeResponse:
content = b"mp3"
def raise_for_status(self):
pass
def fake_post(url, headers, json, timeout):
captured["url"] = url
captured["headers"] = headers
captured["json"] = json
captured["timeout"] = timeout
return FakeResponse()
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
monkeypatch.setattr("requests.post", fake_post)
out = tmp_path / "out.mp3"
_generate_xai_tts(
"Bonjour Monsieur Talbot. Ceci est un test.",
str(out),
{"xai": {"voice_id": "ara", "language": "fr", "auto_speech_tags": True}},
)
assert out.read_bytes() == b"mp3"
assert captured["url"] == "https://api.x.ai/v1/tts"
assert captured["json"]["voice_id"] == "ara"
assert captured["json"]["language"] == "fr"
assert captured["json"]["text"] == "Bonjour Monsieur Talbot. [pause] Ceci est un test."
def test_generate_xai_tts_leaves_text_plain_by_default(tmp_path, monkeypatch):
captured = {}
fake_response = Mock()
fake_response.content = b"mp3"
fake_response.raise_for_status.return_value = None
def fake_post(url, headers, json, timeout):
captured["json"] = json
return fake_response
monkeypatch.setenv("XAI_API_KEY", "test-xai-key")
monkeypatch.setattr("requests.post", fake_post)
_generate_xai_tts(
"Bonjour Monsieur Talbot. Ceci est un test.",
str(tmp_path / "out.mp3"),
{"xai": {"voice_id": "ara", "language": "fr"}},
)
assert captured["json"]["text"] == "Bonjour Monsieur Talbot. Ceci est un test."