mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 19:06:11 +00:00
Merge master into the Gemini and Antigravity branch
Both sides rewrote the doctor: master rebuilt it around ready flags so a fully local setup stops reading as broken, while this branch taught it that Google has a key of its own and that the local model has neither a key nor a program. Kept master's structure and folded the branch in: the two hosted providers answer for their keys, a CLI for its program, and the JSON keeps both the provider-aware "key" and master's "ready". SECRET_KEYS gained groq on master and gemini here; the resolution keeps all four. _conclude was also changed by both: master stopped matching stderr wording and asks _API_TROUBLE instead, this branch renamed its last parameter to the provider's short name so a session is stored under the name it is read back by; the rename now rides on master's body. codex_models() and the settings loaders landed beside the new Gemini ones, so both stay. The new cleanup tests called the CLI fake by its old name, fake_run, which master had renamed to fake_cli.
This commit is contained in:
+195
-19
@@ -10,21 +10,28 @@ import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from dikte import assistant
|
||||
from tests.support import DikteTest, fake_urlopen, only_these_tools
|
||||
from tests.support import (DikteTest, FakeCompleted, fake_urlopen,
|
||||
only_these_tools)
|
||||
|
||||
|
||||
class FakeCli:
|
||||
"""A CLI that prints the given events and exits."""
|
||||
"""A CLI that prints the given events and exits.
|
||||
|
||||
def __init__(self, events=(), code=0, stderr="", noise=()):
|
||||
Its stderr is not modelled: _stream hands the process a temporary file for
|
||||
that, and a mocked Popen leaves the file empty, which is what a quiet CLI
|
||||
writes anyway.
|
||||
"""
|
||||
|
||||
def __init__(self, events=(), code=0, noise=()):
|
||||
lines = list(noise) + [json.dumps(event) for event in events]
|
||||
self.stdout = io.StringIO("\n".join(lines) + "\n")
|
||||
self.stderr = io.StringIO(stderr)
|
||||
self.returncode = code
|
||||
self.killed = False
|
||||
|
||||
@@ -41,6 +48,38 @@ class FakeCli:
|
||||
self.killed = True
|
||||
|
||||
|
||||
class WedgedCli:
|
||||
"""A CLI whose stdout produces nothing, the way a hung process's does.
|
||||
|
||||
Iterating its stdout blocks until the kill arrives, because that is what
|
||||
reading a silent pipe does: only the process ending closes the stream.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.pid = 4242
|
||||
self.returncode = None
|
||||
self.released = threading.Event()
|
||||
self.stdout = self
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
# The 5 second cap is a safety net for the test itself; the kill is
|
||||
# what is supposed to end the wait.
|
||||
self.released.wait(timeout=5)
|
||||
raise StopIteration
|
||||
|
||||
def poll(self):
|
||||
return self.returncode
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return self.returncode
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Provider(DikteTest):
|
||||
def test_the_default(self):
|
||||
self.assertEqual(assistant.provider(self.config()), "claude")
|
||||
@@ -247,19 +286,7 @@ class Denials(DikteTest):
|
||||
self.assertIn("Write", warning)
|
||||
|
||||
|
||||
class SessionMissing(unittest.TestCase):
|
||||
def test_a_session_that_is_gone(self):
|
||||
for text in ("Error: session abc not found",
|
||||
"No conversation with that id",
|
||||
"unknown thread: abc"):
|
||||
with self.subTest(text=text):
|
||||
self.assertTrue(assistant._session_missing(text))
|
||||
|
||||
def test_an_unrelated_failure(self):
|
||||
for text in ("", "network unreachable", "session limit exceeded"):
|
||||
with self.subTest(text=text):
|
||||
self.assertFalse(assistant._session_missing(text))
|
||||
|
||||
class LastLine(unittest.TestCase):
|
||||
def test_the_last_line_is_the_one_worth_showing(self):
|
||||
self.assertEqual(assistant.last_line("warning\n\nreal error\n"),
|
||||
"real error")
|
||||
@@ -302,6 +329,28 @@ class Conclude(DikteTest):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
"abc", "claude")
|
||||
|
||||
def test_the_recovery_no_longer_hangs_on_the_words_the_cli_chose(self):
|
||||
# The complaint used to be matched by substring, which a CLI update or
|
||||
# another language broke. A resumed run that died with nothing to show
|
||||
# is now enough on its own.
|
||||
for stderr in ("Oturum bulunamadı", "something else entirely", ""):
|
||||
with self.subTest(stderr=stderr):
|
||||
with self.assertRaises(assistant._SessionGone):
|
||||
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||
|
||||
def test_api_trouble_on_a_resumed_run_is_not_blamed_on_the_session(self):
|
||||
# A fresh session cannot cure a spent quota, a signed-out CLI or a dead
|
||||
# network: the retry would fail the same way after a second wait, and
|
||||
# the user would lose the conversation thread on top.
|
||||
for stderr in ("Rate limit exceeded",
|
||||
"You are not logged in. Please run /login.",
|
||||
"API Error: 401 Unauthorized",
|
||||
"fetch failed: ECONNREFUSED 127.0.0.1"):
|
||||
with self.subTest(stderr=stderr):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(), 1, stderr, "abc", "Claude")
|
||||
self.assertIn(stderr, str(caught.exception))
|
||||
|
||||
def test_a_session_that_is_gone_only_matters_when_one_was_resumed(self):
|
||||
with self.assertRaises(assistant.AssistantError):
|
||||
assistant._conclude(self.found(), 1, "session abc not found",
|
||||
@@ -312,6 +361,11 @@ class Conclude(DikteTest):
|
||||
"", "claude")
|
||||
self.assertEqual(answer, "done")
|
||||
|
||||
def test_an_answer_on_a_resumed_session_is_kept_rather_than_retried(self):
|
||||
answer, _ = assistant._conclude(self.found(answer="done"), 1, "noise",
|
||||
"abc", "Claude")
|
||||
self.assertEqual(answer, "done")
|
||||
|
||||
def test_a_reported_failure_with_no_answer(self):
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._conclude(self.found(failure="the model refused"), 0, "",
|
||||
@@ -324,14 +378,53 @@ class Conclude(DikteTest):
|
||||
self.assertIn("Codex", str(caught.exception))
|
||||
|
||||
|
||||
class Stream(DikteTest):
|
||||
def test_a_cli_that_floods_stderr_still_finishes(self):
|
||||
# A real subprocess, because the wedge being tested is real plumbing:
|
||||
# with stderr on a pipe nobody drains, 200 KB fills the pipe's buffer,
|
||||
# the child blocks writing it, and the run hangs until the watchdog
|
||||
# timeout. With stderr on a file the run completes at once.
|
||||
script = (
|
||||
"import sys\n"
|
||||
"sys.stderr.write('x' * 200000)\n"
|
||||
"sys.stderr.flush()\n"
|
||||
"print('{\"type\": \"result\", \"result\": \"done\"}')\n"
|
||||
)
|
||||
conf = self.config(assistant_timeout=15)
|
||||
events = []
|
||||
code, stderr = assistant._stream(
|
||||
[sys.executable, "-c", script], conf, events.append, None)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(len(stderr), 200000)
|
||||
self.assertEqual(events[-1]["result"], "done")
|
||||
|
||||
def test_the_watchdog_takes_the_whole_tree_down_on_timeout(self):
|
||||
proc = WedgedCli()
|
||||
|
||||
def killed(target):
|
||||
# What the real kill does, as far as _stream can see: the process
|
||||
# ends, and its closing stream releases the blocked read.
|
||||
target.returncode = 1
|
||||
target.released.set()
|
||||
|
||||
conf = self.config(assistant_timeout=0)
|
||||
with mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||
mock.patch.object(assistant, "kill_tree",
|
||||
side_effect=killed) as kill:
|
||||
with self.assertRaises(assistant.AssistantError) as caught:
|
||||
assistant._stream(["claude"], conf, lambda event: None, None)
|
||||
kill.assert_called_once_with(proc)
|
||||
self.assertIn("did not finish", str(caught.exception))
|
||||
|
||||
|
||||
class AskClaude(DikteTest):
|
||||
def run_ask(self, conf=None, events=None, code=0, stderr="", noise=(),
|
||||
def run_ask(self, conf=None, events=None, code=0, noise=(),
|
||||
session=""):
|
||||
conf = conf or self.config()
|
||||
proc = FakeCli(events or [
|
||||
{"type": "system", "subtype": "init", "session_id": "abc"},
|
||||
{"type": "result", "session_id": "abc", "result": " done "},
|
||||
], code=code, stderr=stderr, noise=noise)
|
||||
], code=code, noise=noise)
|
||||
stages = []
|
||||
with only_these_tools("claude", "codex"), \
|
||||
mock.patch.object(subprocess, "Popen", return_value=proc) as popen:
|
||||
@@ -669,6 +762,89 @@ class Ask(DikteTest):
|
||||
self.assertEqual(attempts, ["stale-id", ""])
|
||||
self.assertEqual(assistant.stored_provider(), "")
|
||||
|
||||
def test_a_resumed_run_that_dies_is_retried_without_the_session_flag(self):
|
||||
# All the way through the stream this time: the first run exits 1 with
|
||||
# no answer and whatever stderr it liked, and the recovery must not
|
||||
# depend on those words.
|
||||
conf = self.config()
|
||||
assistant.write_session("claude", "stale-id")
|
||||
procs = iter([
|
||||
FakeCli(code=1),
|
||||
FakeCli(events=[{"type": "result", "result": "done"}]),
|
||||
])
|
||||
cmds = []
|
||||
|
||||
def popen(cmd, **kwargs):
|
||||
cmds.append(cmd)
|
||||
return next(procs)
|
||||
|
||||
with only_these_tools("claude"), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||
answer, _ = assistant.ask("hi", conf)
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(cmds[0][cmds[0].index("--resume") + 1], "stale-id")
|
||||
self.assertNotIn("--resume", cmds[1])
|
||||
|
||||
def test_a_run_that_dies_with_an_answer_in_hand_is_not_retried(self):
|
||||
conf = self.config()
|
||||
assistant.write_session("claude", "stale-id")
|
||||
calls = []
|
||||
|
||||
def popen(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return FakeCli(events=[{"type": "result", "result": "done"}], code=1)
|
||||
|
||||
with only_these_tools("claude"), \
|
||||
mock.patch.object(subprocess, "Popen", side_effect=popen):
|
||||
answer, _ = assistant.ask("hi", conf)
|
||||
self.assertEqual(answer, "done")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
class CodexModels(DikteTest):
|
||||
"""The model list read off `codex debug models`."""
|
||||
|
||||
CATALOG = {"models": [
|
||||
{"slug": "gpt-6-mini", "visibility": "list", "priority": 9},
|
||||
{"slug": "gpt-6", "visibility": "list", "priority": 1},
|
||||
{"slug": "codex-auto-review", "visibility": "hide", "priority": 3},
|
||||
]}
|
||||
|
||||
def models(self, reply, code=0):
|
||||
with only_these_tools("codex"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
return_value=FakeCompleted(
|
||||
returncode=code, stdout=reply)) as run:
|
||||
found = assistant.codex_models()
|
||||
self.run_call = run
|
||||
return found
|
||||
|
||||
def test_the_catalog_arrives_best_first_without_the_hidden_ones(self):
|
||||
found = self.models(json.dumps(self.CATALOG))
|
||||
self.assertEqual(found, ["gpt-6", "gpt-6-mini"])
|
||||
self.assertEqual(self.run_call.call_args.args[0],
|
||||
["codex", "debug", "models"])
|
||||
|
||||
def test_a_codex_that_is_not_installed_is_not_run(self):
|
||||
with only_these_tools(), \
|
||||
mock.patch.object(subprocess, "run") as run:
|
||||
self.assertEqual(assistant.codex_models(), [])
|
||||
run.assert_not_called()
|
||||
|
||||
def test_a_codex_too_old_to_have_the_command(self):
|
||||
self.assertEqual(self.models("error: unknown subcommand", code=2), [])
|
||||
|
||||
def test_a_catalog_that_is_not_what_was_expected(self):
|
||||
self.assertEqual(self.models(json.dumps(["gpt-6"])), [])
|
||||
self.assertEqual(self.models(""), [])
|
||||
|
||||
def test_a_codex_that_hangs_is_given_up_on(self):
|
||||
with only_these_tools("codex"), \
|
||||
mock.patch.object(subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired(
|
||||
["codex"], 30)):
|
||||
self.assertEqual(assistant.codex_models(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user