Wilker commited on
Commit
f55eea6
·
1 Parent(s): a7bd269

feat: retry testado, tiktoken real, status bar e /status

Browse files
tests/test_agente_real_pexpect.py CHANGED
@@ -58,14 +58,17 @@ class TestAgenteRealEdit(unittest.TestCase):
58
  )
59
  try:
60
  child.expect("❯")
61
- prompt = f"edite o arquivo {self.test_file} substituindo 'linha original' por 'linha modificada'"
62
  child.sendline(prompt)
63
- child.expect(r"str_replace.*\? :")
64
- child.sendline("n")
65
- child.expect("❯")
 
 
 
 
66
  child.sendline("/sair")
67
  child.expect(pexpect.EOF, timeout=10)
68
- self.assertEqual(self.test_file.read_text(), original)
69
  finally:
70
  child.close(force=True)
71
 
@@ -78,12 +81,13 @@ class TestAgenteRealEdit(unittest.TestCase):
78
  )
79
  try:
80
  child.expect("❯")
81
- prompt = f"edite o arquivo {self.test_file} substituindo 'trecho que nao existe' por 'outro'"
82
  child.sendline(prompt)
83
  child.expect("❯", timeout=50)
84
  saida = child.before
85
- self.assertIn("old_str", saida)
86
  self.assertNotIn("Permitir execução", saida)
 
 
87
  child.sendline("/sair")
88
  child.expect(pexpect.EOF, timeout=10)
89
  finally:
@@ -211,6 +215,8 @@ class TestAgenteRealEdit(unittest.TestCase):
211
  else:
212
  break
213
  saida = child.before
 
 
214
  self.assertIn("✓", saida, msg=f"Sem confirmação de tool_result para prompt: {prompt}")
215
  finally:
216
  child.close(force=True)
 
58
  )
59
  try:
60
  child.expect("❯")
61
+ prompt = f"Use a ferramenta str_replace para editar o arquivo {self.test_file} substituindo 'linha original' por 'linha modificada'"
62
  child.sendline(prompt)
63
+ while True:
64
+ idx = child.expect([r"❯", r".*\? :", pexpect.EOF, pexpect.TIMEOUT], timeout=40)
65
+ if idx == 1:
66
+ child.sendline("n")
67
+ else:
68
+ break
69
+ self.assertEqual(self.test_file.read_text(), original)
70
  child.sendline("/sair")
71
  child.expect(pexpect.EOF, timeout=10)
 
72
  finally:
73
  child.close(force=True)
74
 
 
81
  )
82
  try:
83
  child.expect("❯")
84
+ prompt = f"Use a ferramenta str_replace no arquivo {self.test_file} substituindo 'trecho que nao existe' por 'outro'"
85
  child.sendline(prompt)
86
  child.expect("❯", timeout=50)
87
  saida = child.before
 
88
  self.assertNotIn("Permitir execução", saida)
89
+ self.assertIn("não encontrado", saida or "")
90
+ self.assertEqual(self.test_file.read_text(), "linha original\n")
91
  child.sendline("/sair")
92
  child.expect(pexpect.EOF, timeout=10)
93
  finally:
 
215
  else:
216
  break
217
  saida = child.before
218
+ if "429" in saida or "Too Many Requests" in saida:
219
+ self.skipTest(f"Rate limit do tier free do Groq atingido no prompt: {prompt}")
220
  self.assertIn("✓", saida, msg=f"Sem confirmação de tool_result para prompt: {prompt}")
221
  finally:
222
  child.close(force=True)
tests/test_robustez_fase1b_2_1.py CHANGED
@@ -82,6 +82,32 @@ class TestRetryMalformado(unittest.TestCase):
82
  self.assertIsInstance(result, str)
83
  self.assertNotEqual(result, "")
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  class TestSessionPersistence(unittest.TestCase):
87
  def setUp(self):
 
82
  self.assertIsInstance(result, str)
83
  self.assertNotEqual(result, "")
84
 
85
+ def test_retry_corrige_json_malformado_e_executa_tool(self):
86
+ import tempfile
87
+ from pathlib import Path
88
+
89
+ tmp = tempfile.TemporaryDirectory()
90
+ target = Path(tmp.name) / "out.txt"
91
+ target.write_text("ola", encoding="utf-8")
92
+
93
+ provider = MagicMock()
94
+ provider.name = "thc"
95
+ provider.chat_completion.side_effect = [
96
+ {"choices": [{"message": {"content": "```tool_call\n{\"name\": \"write_file\", \"arguments\": {\"path\": \"" + str(target) + "\", \"content\": \"mundo\"}},\n```"}}]},
97
+ {"choices": [{"message": {"content": "```tool_call\n{\"name\": \"write_file\", \"arguments\": {\"path\": \"" + str(target) + "\", \"content\": \"mundo\"}}\n```"}}]},
98
+ {"choices": [{"message": {"content": "Arquivo atualizado com sucesso."}}]},
99
+ ]
100
+ result = run_agent(
101
+ provider=provider,
102
+ messages=[{"role": "user", "content": "escreva 'mundo' em " + str(target)}],
103
+ max_rounds=5,
104
+ on_tool_call=lambda name, args: None,
105
+ on_tool_result=lambda name, output: None,
106
+ )
107
+ self.assertEqual(target.read_text(encoding="utf-8"), "mundo")
108
+ self.assertEqual(result, "Arquivo atualizado com sucesso.")
109
+ tmp.cleanup()
110
+
111
 
112
  class TestSessionPersistence(unittest.TestCase):
113
  def setUp(self):
tests/test_tokens.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import unittest
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from thc_cli.core.tokens import count_tokens, count_tokens_messages
6
+
7
+
8
+ class TestCountTokens(unittest.TestCase):
9
+ def test_string_nao_vazia_retorna_inteiro_positivo(self):
10
+ text = "hello world"
11
+ result = count_tokens(text)
12
+ self.assertIsInstance(result, int)
13
+ self.assertGreater(result, 0)
14
+
15
+ def test_cresce_proporcionalmente(self):
16
+ short = "a" * 10
17
+ long = "a" * 100
18
+ self.assertLess(count_tokens(short), count_tokens(long))
19
+
20
+ def test_string_vazia_retorna_zero(self):
21
+ self.assertEqual(count_tokens(""), 0)
22
+
23
+ def test_fallback_sem_tiktoken_nao_crash(self):
24
+ with patch.dict(sys.modules, {"tiktoken": None}):
25
+ import importlib
26
+ import thc_cli.core.tokens as tokens_mod
27
+ importlib.reload(tokens_mod)
28
+ result = tokens_mod.count_tokens("hello world")
29
+ self.assertEqual(result, len("hello world") // 4)
30
+
31
+ def test_count_tokens_messages_soma_corretamente(self):
32
+ messages = [
33
+ {"role": "user", "content": "primeira mensagem"},
34
+ {"role": "assistant", "content": "segunda mensagem resposta"},
35
+ {"role": "user", "content": "terceira mensagem"},
36
+ ]
37
+ total = count_tokens_messages(messages)
38
+ expected = count_tokens("primeira mensagem") + count_tokens("segunda mensagem resposta") + count_tokens("terceira mensagem")
39
+ self.assertEqual(total, expected)
40
+
41
+ def test_count_tokens_messages_ignora_campos_nao_string(self):
42
+ messages = [
43
+ {"role": "user", "content": "ola"},
44
+ {"role": "assistant", "content": None},
45
+ {"role": "user", "content": 123},
46
+ ]
47
+ total = count_tokens_messages(messages)
48
+ self.assertEqual(total, count_tokens("ola"))
49
+
50
+
51
+ if __name__ == "__main__":
52
+ unittest.main(verbosity=2)
thc_cli/commands/chat.py CHANGED
@@ -1,6 +1,7 @@
1
  import argparse
2
  import re
3
  import sys
 
4
 
5
  from rich.console import Console
6
  from rich.markdown import Markdown
@@ -13,6 +14,7 @@ from ..core.agent import run_agent
13
  from ..core.providers import get_provider
14
  from ..core.session import list_sessions, load_session, save_session
15
  from ..core.system_prompt import build_base_system_prompt
 
16
  from ..core.tools import DESTRUCTIVE_TOOLS, TOOLS_BY_NAME
17
 
18
  console = Console()
@@ -95,6 +97,12 @@ def _provider_chat(provider, messages, model, mode, web, max_tokens=8192, temper
95
  )
96
 
97
 
 
 
 
 
 
 
98
  def register(subparsers):
99
  parser = subparsers.add_parser("chat", help="Chat interativo com TUI rica")
100
  parser.add_argument("prompt", nargs="?", help="Texto da pergunta")
@@ -121,6 +129,8 @@ def run(args, config):
121
  max_tokens = args.max_tokens or 2048
122
  history = []
123
  current_session_id = None
 
 
124
 
125
  def _render_reply(text: str):
126
  console.print(Markdown(text))
@@ -206,6 +216,8 @@ def run(args, config):
206
  print(f"Erro: {e}", file=sys.stderr)
207
  sys.exit(1)
208
  _render_reply(reply)
 
 
209
  return
210
 
211
  _banner(model, mode, web, agent_mode, provider_name)
@@ -284,6 +296,20 @@ def run(args, config):
284
  )
285
  console.print(table)
286
  continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
  if cmd == "/save":
288
  try:
289
  saved_id = save_session(current_session_id, history, provider_name, model or "", mode or "", agent_mode=agent_mode)
@@ -367,5 +393,7 @@ def run(args, config):
367
  reply = result["choices"][0]["message"]["content"]
368
  history.append({"role": "assistant", "content": reply})
369
  _render_reply(reply)
 
 
370
  except Exception as e:
371
  console.print(f"[red]Erro: {e}[/red]")
 
1
  import argparse
2
  import re
3
  import sys
4
+ import time
5
 
6
  from rich.console import Console
7
  from rich.markdown import Markdown
 
14
  from ..core.providers import get_provider
15
  from ..core.session import list_sessions, load_session, save_session
16
  from ..core.system_prompt import build_base_system_prompt
17
+ from ..core.tokens import count_tokens, count_tokens_messages
18
  from ..core.tools import DESTRUCTIVE_TOOLS, TOOLS_BY_NAME
19
 
20
  console = Console()
 
97
  )
98
 
99
 
100
+ def _format_elapsed(seconds: float) -> str:
101
+ minutes = int(seconds // 60)
102
+ secs = int(seconds % 60)
103
+ return f"{minutes}m{secs:02d}s"
104
+
105
+
106
  def register(subparsers):
107
  parser = subparsers.add_parser("chat", help="Chat interativo com TUI rica")
108
  parser.add_argument("prompt", nargs="?", help="Texto da pergunta")
 
129
  max_tokens = args.max_tokens or 2048
130
  history = []
131
  current_session_id = None
132
+ session_start_time = time.time()
133
+ session_tokens_used = 0
134
 
135
  def _render_reply(text: str):
136
  console.print(Markdown(text))
 
216
  print(f"Erro: {e}", file=sys.stderr)
217
  sys.exit(1)
218
  _render_reply(reply)
219
+ session_tokens_used += count_tokens(args.prompt or "") + count_tokens(reply)
220
+ console.print(f"[dim]🪙 {session_tokens_used} tokens · ⏱ {_format_elapsed(time.time() - session_start_time)}[/dim]")
221
  return
222
 
223
  _banner(model, mode, web, agent_mode, provider_name)
 
296
  )
297
  console.print(table)
298
  continue
299
+ if cmd == "/status":
300
+ status_lines = [
301
+ f"provider: [bold green]{provider_name}[/bold green]",
302
+ f"modelo: [bold green]{model or 'padrão'}[/bold green]",
303
+ f"modo: [bold cyan]{mode}[/bold cyan]",
304
+ f"web: [bold {'green' if web else 'red'}]{'on' if web else 'off'}[/bold {'green' if web else 'red'}]",
305
+ f"agente: [bold {'green' if agent_mode else 'red'}]{'on' if agent_mode else 'off'}[/bold {'green' if agent_mode else 'red'}]",
306
+ f"tokens: [bold yellow]{session_tokens_used}[/bold yellow]",
307
+ f"tempo: [bold yellow]{_format_elapsed(time.time() - session_start_time)}[/bold yellow]",
308
+ f"mensagens: [bold yellow]{len(history)}[/bold yellow]",
309
+ f"session_id: [bold cyan]{current_session_id or 'nenhuma'}[/bold cyan]",
310
+ ]
311
+ console.print(Panel("\n".join(status_lines), title="Status", border_style="cyan"))
312
+ continue
313
  if cmd == "/save":
314
  try:
315
  saved_id = save_session(current_session_id, history, provider_name, model or "", mode or "", agent_mode=agent_mode)
 
393
  reply = result["choices"][0]["message"]["content"]
394
  history.append({"role": "assistant", "content": reply})
395
  _render_reply(reply)
396
+ session_tokens_used += count_tokens(user_input or "") + count_tokens(reply)
397
+ console.print(f"[dim]🪙 {session_tokens_used} tokens · ⏱ {_format_elapsed(time.time() - session_start_time)}[/dim]")
398
  except Exception as e:
399
  console.print(f"[red]Erro: {e}[/red]")
thc_cli/core/tokens.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def count_tokens(text: str) -> int:
2
+ if not text:
3
+ return 0
4
+ try:
5
+ import tiktoken
6
+ except ImportError:
7
+ return max(0, len(text) // 4)
8
+ enc = tiktoken.get_encoding("cl100k_base")
9
+ tokens = enc.encode(text)
10
+ return len(tokens)
11
+
12
+
13
+ def count_tokens_messages(messages: list[dict]) -> int:
14
+ total = 0
15
+ for message in messages:
16
+ content = message.get("content")
17
+ if isinstance(content, str):
18
+ total += count_tokens(content)
19
+ return total