god-code 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {god_code-0.2.2 → god_code-0.3.0}/PKG-INFO +1 -1
  2. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/cli.py +46 -2
  3. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/client.py +40 -2
  4. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/engine.py +28 -6
  5. {god_code-0.2.2 → god_code-0.3.0}/pyproject.toml +1 -1
  6. {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_engine.py +37 -59
  7. {god_code-0.2.2 → god_code-0.3.0}/tests/test_e2e.py +9 -5
  8. {god_code-0.2.2 → god_code-0.3.0}/.github/workflows/publish.yml +0 -0
  9. {god_code-0.2.2 → god_code-0.3.0}/.gitignore +0 -0
  10. {god_code-0.2.2 → god_code-0.3.0}/CHANGELOG.md +0 -0
  11. {god_code-0.2.2 → god_code-0.3.0}/CLAUDE.md +0 -0
  12. {god_code-0.2.2 → god_code-0.3.0}/CONTRIBUTING.md +0 -0
  13. {god_code-0.2.2 → god_code-0.3.0}/LICENSE +0 -0
  14. {god_code-0.2.2 → god_code-0.3.0}/README.md +0 -0
  15. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/__init__.py +0 -0
  16. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/__init__.py +0 -0
  17. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/collision_planner.py +0 -0
  18. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/consistency_checker.py +0 -0
  19. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/dependency_graph.py +0 -0
  20. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/gdscript_linter.py +0 -0
  21. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/pattern_advisor.py +0 -0
  22. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/project.py +0 -0
  23. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/resource_validator.py +0 -0
  24. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/scene_parser.py +0 -0
  25. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/scene_writer.py +0 -0
  26. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/godot/tscn_validator.py +0 -0
  27. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/__init__.py +0 -0
  28. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/streaming.py +0 -0
  29. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/llm/vision.py +0 -0
  30. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/__init__.py +0 -0
  31. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/build_discipline.py +0 -0
  32. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/godot_playbook.py +0 -0
  33. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/knowledge_selector.py +0 -0
  34. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/prompts/system.py +0 -0
  35. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/py.typed +0 -0
  36. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/__init__.py +0 -0
  37. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/auth.py +0 -0
  38. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/config.py +0 -0
  39. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/context_manager.py +0 -0
  40. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/error_loop.py +0 -0
  41. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/oauth.py +0 -0
  42. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/runtime/session.py +0 -0
  43. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/__init__.py +0 -0
  44. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/base.py +0 -0
  45. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/file_ops.py +0 -0
  46. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/git.py +0 -0
  47. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/godot_cli.py +0 -0
  48. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/list_dir.py +0 -0
  49. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/registry.py +0 -0
  50. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/screenshot.py +0 -0
  51. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/search.py +0 -0
  52. {god_code-0.2.2 → god_code-0.3.0}/godot_agent/tools/shell.py +0 -0
  53. {god_code-0.2.2 → god_code-0.3.0}/tests/__init__.py +0 -0
  54. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/__init__.py +0 -0
  55. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_collision_planner.py +0 -0
  56. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_consistency.py +0 -0
  57. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_dependency_graph.py +0 -0
  58. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_linter.py +0 -0
  59. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_pattern_advisor.py +0 -0
  60. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_project.py +0 -0
  61. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_resource_validator.py +0 -0
  62. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_scene_parser.py +0 -0
  63. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_scene_writer.py +0 -0
  64. {god_code-0.2.2 → god_code-0.3.0}/tests/godot/test_tscn_validator.py +0 -0
  65. {god_code-0.2.2 → god_code-0.3.0}/tests/llm/__init__.py +0 -0
  66. {god_code-0.2.2 → god_code-0.3.0}/tests/llm/test_client.py +0 -0
  67. {god_code-0.2.2 → god_code-0.3.0}/tests/llm/test_vision.py +0 -0
  68. {god_code-0.2.2 → god_code-0.3.0}/tests/prompts/__init__.py +0 -0
  69. {god_code-0.2.2 → god_code-0.3.0}/tests/prompts/test_knowledge_selector.py +0 -0
  70. {god_code-0.2.2 → god_code-0.3.0}/tests/prompts/test_system_prompt.py +0 -0
  71. {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/__init__.py +0 -0
  72. {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_config.py +0 -0
  73. {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_context_manager.py +0 -0
  74. {god_code-0.2.2 → god_code-0.3.0}/tests/runtime/test_error_loop.py +0 -0
  75. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/__init__.py +0 -0
  76. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_file_ops.py +0 -0
  77. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_git.py +0 -0
  78. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_godot_cli.py +0 -0
  79. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_list_dir.py +0 -0
  80. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_registry.py +0 -0
  81. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_search.py +0 -0
  82. {god_code-0.2.2 → god_code-0.3.0}/tests/tools/test_shell.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: god-code
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: AI coding agent specialized for Godot game development
5
5
  Project-URL: Homepage, https://github.com/chuisiufai/god-code
6
6
  Project-URL: Repository, https://github.com/chuisiufai/god-code
@@ -195,7 +195,7 @@ def _run_setup_wizard() -> None:
195
195
  click.echo()
196
196
 
197
197
 
198
- _VERSION = "0.2.2"
198
+ _VERSION = "0.3.0"
199
199
 
200
200
 
201
201
  def _check_update() -> None:
@@ -345,6 +345,7 @@ def chat(project: str = ".", config: str | None = None):
345
345
  cmd_table.add_row("/cd <path>", "change project directory")
346
346
  cmd_table.add_row("/info", "show project details")
347
347
  cmd_table.add_row("/status", "show model & auth")
348
+ cmd_table.add_row("/usage", "show token usage & cost")
348
349
  cmd_table.add_row("/save", "save session")
349
350
  cmd_table.add_row("/quit", "exit")
350
351
  console.print(cmd_table)
@@ -398,6 +399,20 @@ def chat(project: str = ".", config: str | None = None):
398
399
  console.print(f"[yellow] No project.godot in {project_root}[/]")
399
400
  continue
400
401
 
402
+ if cmd == "/usage":
403
+ sess = engine.session_usage
404
+ cost = sess.cost_estimate(cfg.model)
405
+ usage_table = Table(show_header=False, box=None, padding=(0, 1))
406
+ usage_table.add_column(style="bold")
407
+ usage_table.add_column()
408
+ usage_table.add_row("Input tokens", f"{sess.prompt_tokens:,}")
409
+ usage_table.add_row("Output tokens", f"{sess.completion_tokens:,}")
410
+ usage_table.add_row("Total tokens", f"{sess.total_tokens:,}")
411
+ usage_table.add_row("API calls", str(engine.session_api_calls))
412
+ usage_table.add_row("Est. cost", f"${cost:.4f}")
413
+ console.print(Panel(usage_table, title="Usage", border_style="blue"))
414
+ continue
415
+
401
416
  if cmd == "/status":
402
417
  st = Table(show_header=False, box=None, padding=(0, 1))
403
418
  st.add_column(style="bold")
@@ -446,12 +461,41 @@ def chat(project: str = ".", config: str | None = None):
446
461
  border_style="cyan",
447
462
  padding=(1, 2),
448
463
  ))
464
+
465
+ # Show token usage
466
+ turn = engine.last_turn
467
+ sess = engine.session_usage
468
+ if turn:
469
+ cost_turn = turn.usage.cost_estimate(cfg.model)
470
+ cost_sess = sess.cost_estimate(cfg.model)
471
+ tools_str = f" tools: {', '.join(turn.tools_called)}" if turn.tools_called else ""
472
+ console.print(
473
+ f" [dim]tokens: {turn.usage.total_tokens:,} "
474
+ f"(in:{turn.usage.prompt_tokens:,} out:{turn.usage.completion_tokens:,}) "
475
+ f"~${cost_turn:.4f}"
476
+ f"{tools_str}[/]"
477
+ )
478
+ console.print(
479
+ f" [dim]session: {sess.total_tokens:,} tokens "
480
+ f"| {engine.session_api_calls} API calls "
481
+ f"| ~${cost_sess:.4f} total[/]"
482
+ )
449
483
  console.print()
450
484
  finally:
451
485
  await engine.close()
452
486
 
453
487
  asyncio.run(_loop())
454
- console.print("[dim]Session ended.[/]")
488
+ # Session summary
489
+ sess = engine.session_usage
490
+ cost = sess.cost_estimate(cfg.model)
491
+ console.print()
492
+ console.print(Panel(
493
+ f"Tokens: {sess.total_tokens:,} (in:{sess.prompt_tokens:,} out:{sess.completion_tokens:,})\n"
494
+ f"API calls: {engine.session_api_calls}\n"
495
+ f"Estimated cost: ${cost:.4f}",
496
+ title="[dim]Session Summary[/]",
497
+ border_style="dim",
498
+ ))
455
499
 
456
500
 
457
501
  @main.command()
@@ -7,11 +7,42 @@ import httpx
7
7
  log = logging.getLogger(__name__)
8
8
 
9
9
 
10
+ @dataclass
11
+ class TokenUsage:
12
+ prompt_tokens: int = 0
13
+ completion_tokens: int = 0
14
+ total_tokens: int = 0
15
+
16
+ def __add__(self, other: TokenUsage) -> TokenUsage:
17
+ return TokenUsage(
18
+ prompt_tokens=self.prompt_tokens + other.prompt_tokens,
19
+ completion_tokens=self.completion_tokens + other.completion_tokens,
20
+ total_tokens=self.total_tokens + other.total_tokens,
21
+ )
22
+
23
+ def cost_estimate(self, model: str = "") -> float:
24
+ """Estimate cost in USD based on model pricing."""
25
+ # Pricing per 1M tokens (input/output)
26
+ pricing = {
27
+ "gpt-5.4": (2.50, 10.00),
28
+ "gpt-4o": (2.50, 10.00),
29
+ "gpt-4o-mini": (0.15, 0.60),
30
+ }
31
+ inp_rate, out_rate = pricing.get(model, (2.50, 10.00))
32
+ return (self.prompt_tokens * inp_rate + self.completion_tokens * out_rate) / 1_000_000
33
+
34
+
10
35
  @dataclass
11
36
  class ToolCall:
12
37
  id: str
13
38
  name: str
14
- arguments: str # JSON string
39
+ arguments: str
40
+
41
+
42
+ @dataclass
43
+ class ChatResponse:
44
+ message: Message
45
+ usage: TokenUsage
15
46
 
16
47
 
17
48
  @dataclass
@@ -145,7 +176,14 @@ class LLMClient:
145
176
  )
146
177
  for tc in choice["tool_calls"]
147
178
  ]
148
- return Message.assistant(content=choice.get("content"), tool_calls=tool_calls)
179
+ msg = Message.assistant(content=choice.get("content"), tool_calls=tool_calls)
180
+ usage_data = data.get("usage", {})
181
+ usage = TokenUsage(
182
+ prompt_tokens=usage_data.get("prompt_tokens", 0),
183
+ completion_tokens=usage_data.get("completion_tokens", 0),
184
+ total_tokens=usage_data.get("total_tokens", 0),
185
+ )
186
+ return ChatResponse(message=msg, usage=usage)
149
187
 
150
188
  async def close(self):
151
189
  await self._http.aclose()
@@ -4,8 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
+ from dataclasses import dataclass, field
7
8
 
8
- from godot_agent.llm.client import LLMClient, Message
9
+ from godot_agent.llm.client import ChatResponse, LLMClient, Message, TokenUsage
9
10
  from godot_agent.runtime.context_manager import compact_messages, estimate_tokens
10
11
  from godot_agent.runtime.error_loop import format_validation_for_llm, validate_project
11
12
  from godot_agent.tools.registry import ToolRegistry
@@ -13,11 +14,17 @@ from godot_agent.tools.registry import ToolRegistry
13
14
  log = logging.getLogger(__name__)
14
15
 
15
16
  _COMPACT_THRESHOLD = 80000
16
-
17
- # Tools that modify files — trigger Godot validation after execution
18
17
  _FILE_MUTATING_TOOLS = {"write_file", "edit_file"}
19
18
 
20
19
 
20
+ @dataclass
21
+ class TurnStats:
22
+ """Stats for a single submit() call (may include multiple LLM round-trips)."""
23
+ usage: TokenUsage = field(default_factory=TokenUsage)
24
+ api_calls: int = 0
25
+ tools_called: list[str] = field(default_factory=list)
26
+
27
+
21
28
  class ConversationEngine:
22
29
  def __init__(
23
30
  self,
@@ -34,6 +41,10 @@ class ConversationEngine:
34
41
  self.messages: list[Message] = [Message.system(system_prompt)]
35
42
  self.project_path = project_path
36
43
  self.godot_path = godot_path
44
+ # Cumulative session stats
45
+ self.session_usage = TokenUsage()
46
+ self.session_api_calls = 0
47
+ self.last_turn: TurnStats | None = None
37
48
 
38
49
  async def _maybe_compact(self) -> None:
39
50
  total = sum(estimate_tokens(str(m.content or "")) for m in self.messages)
@@ -42,7 +53,6 @@ class ConversationEngine:
42
53
  self.messages = compact_messages(self.messages, keep_recent=8)
43
54
 
44
55
  async def _post_tool_validate(self, tool_names: set[str]) -> str | None:
45
- """Run Godot validation after file-mutating tools. Returns error report or None."""
46
56
  if not self.project_path:
47
57
  return None
48
58
  if not tool_names & _FILE_MUTATING_TOOLS:
@@ -58,12 +68,23 @@ class ConversationEngine:
58
68
  return None
59
69
 
60
70
  async def _run_loop(self, tools: list[dict] | None) -> str:
71
+ turn = TurnStats()
72
+
61
73
  for _ in range(self.max_tool_rounds + 1):
62
74
  await self._maybe_compact()
63
- response = await self.client.chat(self.messages, tools)
75
+ chat_resp: ChatResponse = await self.client.chat(self.messages, tools)
76
+ response = chat_resp.message
77
+
78
+ # Track usage
79
+ turn.usage = turn.usage + chat_resp.usage
80
+ turn.api_calls += 1
81
+ self.session_usage = self.session_usage + chat_resp.usage
82
+ self.session_api_calls += 1
83
+
64
84
  self.messages.append(response)
65
85
 
66
86
  if not response.tool_calls:
87
+ self.last_turn = turn
67
88
  return response.content or ""
68
89
 
69
90
  tool_names_used: set[str] = set()
@@ -73,6 +94,7 @@ class ConversationEngine:
73
94
  except json.JSONDecodeError:
74
95
  args = {}
75
96
  tool_names_used.add(tc.name)
97
+ turn.tools_called.append(tc.name)
76
98
  result = await self.registry.execute(tc.name, args)
77
99
  if result.error:
78
100
  content = json.dumps({"error": result.error})
@@ -80,7 +102,6 @@ class ConversationEngine:
80
102
  content = json.dumps(result.output.model_dump() if result.output else {})
81
103
  self.messages.append(Message.tool_result(tool_call_id=tc.id, content=content))
82
104
 
83
- # Auto-validate after file mutations
84
105
  validation_report = await self._post_tool_validate(tool_names_used)
85
106
  if validation_report:
86
107
  self.messages.append(Message.user(
@@ -88,6 +109,7 @@ class ConversationEngine:
88
109
  f"Fix the errors before proceeding."
89
110
  ))
90
111
 
112
+ self.last_turn = turn
91
113
  return "Tool call limit reached. Please simplify the request."
92
114
 
93
115
  async def submit(self, user_input: str) -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "god-code"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "AI coding agent specialized for Godot game development"
5
5
  requires-python = ">=3.9"
6
6
  license = {text = "GPL-3.0-or-later"}
@@ -2,26 +2,28 @@ import pytest
2
2
  import json
3
3
  from unittest.mock import AsyncMock
4
4
  from godot_agent.runtime.engine import ConversationEngine
5
- from godot_agent.llm.client import Message, ToolCall, LLMClient
5
+ from godot_agent.llm.client import Message, ToolCall, LLMClient, ChatResponse, TokenUsage
6
6
  from godot_agent.tools.registry import ToolRegistry
7
7
  from godot_agent.tools.base import BaseTool, ToolResult
8
8
  from pydantic import BaseModel
9
9
 
10
10
 
11
+ def _resp(msg: Message) -> ChatResponse:
12
+ """Wrap a Message in a ChatResponse with dummy usage."""
13
+ return ChatResponse(message=msg, usage=TokenUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15))
14
+
15
+
11
16
  class EchoInput(BaseModel):
12
17
  text: str
13
18
 
14
-
15
19
  class EchoOutput(BaseModel):
16
20
  reply: str
17
21
 
18
-
19
22
  class EchoTool(BaseTool):
20
23
  name = "echo"
21
24
  description = "Echo back"
22
25
  Input = EchoInput
23
26
  Output = EchoOutput
24
-
25
27
  async def execute(self, input):
26
28
  return ToolResult(output=EchoOutput(reply=f"echo: {input.text}"))
27
29
 
@@ -30,76 +32,48 @@ class TestConversationEngine:
30
32
  @pytest.mark.asyncio
31
33
  async def test_simple_text_response(self):
32
34
  mock_client = AsyncMock(spec=LLMClient)
33
- mock_client.chat = AsyncMock(return_value=Message.assistant(content="Hello!"))
35
+ mock_client.chat = AsyncMock(return_value=_resp(Message.assistant(content="Hello!")))
34
36
  registry = ToolRegistry()
35
- engine = ConversationEngine(
36
- client=mock_client, registry=registry, system_prompt="You are helpful."
37
- )
37
+ engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="You are helpful.")
38
38
  response = await engine.submit("Hi")
39
39
  assert response == "Hello!"
40
- assert len(engine.messages) == 3 # system + user + assistant
40
+ assert len(engine.messages) == 3
41
41
 
42
42
  @pytest.mark.asyncio
43
43
  async def test_tool_call_and_response(self):
44
44
  registry = ToolRegistry()
45
45
  registry.register(EchoTool())
46
- call_msg = Message.assistant(
47
- tool_calls=[
48
- ToolCall(id="call_1", name="echo", arguments='{"text": "test"}')
49
- ]
50
- )
51
- final_msg = Message.assistant(content="The echo said: echo: test")
46
+ call_msg = _resp(Message.assistant(tool_calls=[ToolCall(id="call_1", name="echo", arguments='{"text": "test"}')]))
47
+ final_msg = _resp(Message.assistant(content="The echo said: echo: test"))
52
48
  mock_client = AsyncMock(spec=LLMClient)
53
49
  mock_client.chat = AsyncMock(side_effect=[call_msg, final_msg])
54
- engine = ConversationEngine(
55
- client=mock_client, registry=registry, system_prompt="test"
56
- )
50
+ engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
57
51
  response = await engine.submit("Echo something")
58
52
  assert "echo: test" in response
59
- # system + user + assistant(tool) + tool_result + assistant(final)
60
53
  assert len(engine.messages) == 5
61
54
 
62
55
  @pytest.mark.asyncio
63
56
  async def test_max_turns_limit(self):
64
57
  registry = ToolRegistry()
65
58
  registry.register(EchoTool())
66
- call_msg = Message.assistant(
67
- tool_calls=[
68
- ToolCall(id="call_1", name="echo", arguments='{"text": "loop"}')
69
- ]
70
- )
59
+ call_msg = _resp(Message.assistant(tool_calls=[ToolCall(id="call_1", name="echo", arguments='{"text": "loop"}')]))
71
60
  mock_client = AsyncMock(spec=LLMClient)
72
61
  mock_client.chat = AsyncMock(return_value=call_msg)
73
- engine = ConversationEngine(
74
- client=mock_client,
75
- registry=registry,
76
- system_prompt="test",
77
- max_tool_rounds=3,
78
- )
62
+ engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test", max_tool_rounds=3)
79
63
  response = await engine.submit("Loop forever")
80
- # Should stop after max_tool_rounds + 1 iterations
81
64
  assert mock_client.chat.call_count == 4
82
65
 
83
66
  @pytest.mark.asyncio
84
67
  async def test_tool_error_forwarded(self):
85
- """Tool execution errors are serialised as JSON error messages."""
86
68
  registry = ToolRegistry()
87
69
  registry.register(EchoTool())
88
- # Malformed arguments will cause Input validation to fail
89
- call_msg = Message.assistant(
90
- tool_calls=[
91
- ToolCall(id="call_err", name="echo", arguments='{"bad_key": 1}')
92
- ]
93
- )
94
- final_msg = Message.assistant(content="Got an error from the tool.")
70
+ call_msg = _resp(Message.assistant(tool_calls=[ToolCall(id="call_err", name="echo", arguments='{"bad_key": 1}')]))
71
+ final_msg = _resp(Message.assistant(content="Got an error from the tool."))
95
72
  mock_client = AsyncMock(spec=LLMClient)
96
73
  mock_client.chat = AsyncMock(side_effect=[call_msg, final_msg])
97
- engine = ConversationEngine(
98
- client=mock_client, registry=registry, system_prompt="test"
99
- )
74
+ engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
100
75
  response = await engine.submit("Break the tool")
101
76
  assert response == "Got an error from the tool."
102
- # Verify the tool_result message contains an error key
103
77
  tool_result_msg = engine.messages[3]
104
78
  assert tool_result_msg.role == "tool"
105
79
  parsed = json.loads(tool_result_msg.content)
@@ -108,31 +82,35 @@ class TestConversationEngine:
108
82
  @pytest.mark.asyncio
109
83
  async def test_submit_with_images(self):
110
84
  mock_client = AsyncMock(spec=LLMClient)
111
- mock_client.chat = AsyncMock(
112
- return_value=Message.assistant(content="I see an image.")
113
- )
85
+ mock_client.chat = AsyncMock(return_value=_resp(Message.assistant(content="I see an image.")))
114
86
  registry = ToolRegistry()
115
- engine = ConversationEngine(
116
- client=mock_client, registry=registry, system_prompt="Vision agent."
117
- )
87
+ engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="Vision agent.")
118
88
  response = await engine.submit_with_images("What is this?", ["base64data"])
119
89
  assert response == "I see an image."
120
90
  assert len(engine.messages) == 3
121
- # The user message should contain image content blocks
122
- user_msg = engine.messages[1]
123
- assert isinstance(user_msg.content, list)
91
+ assert isinstance(engine.messages[1].content, list)
124
92
 
125
93
  @pytest.mark.asyncio
126
94
  async def test_empty_registry_passes_no_tools(self):
127
95
  mock_client = AsyncMock(spec=LLMClient)
128
- mock_client.chat = AsyncMock(
129
- return_value=Message.assistant(content="No tools here.")
130
- )
96
+ mock_client.chat = AsyncMock(return_value=_resp(Message.assistant(content="No tools.")))
131
97
  registry = ToolRegistry()
132
- engine = ConversationEngine(
133
- client=mock_client, registry=registry, system_prompt="test"
134
- )
98
+ engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
135
99
  await engine.submit("Hello")
136
- # When registry is empty, tools param should be None
137
100
  call_args = mock_client.chat.call_args
138
101
  assert call_args[0][1] is None or call_args[1].get("tools") is None
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_usage_tracking(self):
105
+ mock_client = AsyncMock(spec=LLMClient)
106
+ mock_client.chat = AsyncMock(return_value=ChatResponse(
107
+ message=Message.assistant(content="Done"),
108
+ usage=TokenUsage(prompt_tokens=100, completion_tokens=50, total_tokens=150),
109
+ ))
110
+ registry = ToolRegistry()
111
+ engine = ConversationEngine(client=mock_client, registry=registry, system_prompt="test")
112
+ await engine.submit("Test")
113
+ assert engine.session_usage.total_tokens == 150
114
+ assert engine.last_turn is not None
115
+ assert engine.last_turn.usage.total_tokens == 150
116
+ assert engine.session_api_calls == 1
@@ -12,7 +12,11 @@ from unittest.mock import AsyncMock
12
12
 
13
13
  import pytest
14
14
 
15
- from godot_agent.llm.client import LLMClient, Message, ToolCall
15
+ from godot_agent.llm.client import LLMClient, Message, ToolCall, ChatResponse, TokenUsage
16
+
17
+
18
+ def _resp(msg: Message) -> ChatResponse:
19
+ return ChatResponse(message=msg, usage=TokenUsage())
16
20
  from godot_agent.prompts.system import build_system_prompt
17
21
  from godot_agent.runtime.engine import ConversationEngine
18
22
  from godot_agent.tools.file_ops import EditFileTool, ReadFileTool, WriteFileTool
@@ -84,7 +88,7 @@ class TestE2EReadAndEdit:
84
88
  final = Message.assistant(content="Done! Changed speed from 100 to 200.")
85
89
 
86
90
  mock_client = AsyncMock(spec=LLMClient)
87
- mock_client.chat = AsyncMock(side_effect=[read_call, edit_call, final])
91
+ mock_client.chat = AsyncMock(side_effect=[_resp(read_call), _resp(edit_call), _resp(final)])
88
92
 
89
93
  engine = _make_engine(godot_project, mock_client)
90
94
  result = await engine.submit("Change player speed to 200")
@@ -118,7 +122,7 @@ class TestE2ESearch:
118
122
  )
119
123
 
120
124
  mock_client = AsyncMock(spec=LLMClient)
121
- mock_client.chat = AsyncMock(side_effect=[search_call, final])
125
+ mock_client.chat = AsyncMock(side_effect=[_resp(search_call), _resp(final)])
122
126
 
123
127
  engine = _make_engine(godot_project, mock_client)
124
128
  result = await engine.submit("Find where speed is defined")
@@ -148,7 +152,7 @@ class TestE2EWriteNewFile:
148
152
  final = Message.assistant(content="Created enemy.gd with 50 HP.")
149
153
 
150
154
  mock_client = AsyncMock(spec=LLMClient)
151
- mock_client.chat = AsyncMock(side_effect=[write_call, final])
155
+ mock_client.chat = AsyncMock(side_effect=[_resp(write_call), _resp(final)])
152
156
 
153
157
  engine = _make_engine(godot_project, mock_client)
154
158
  result = await engine.submit("Create an enemy script")
@@ -189,7 +193,7 @@ class TestE2EMultiToolChain:
189
193
  )
190
194
 
191
195
  mock_client = AsyncMock(spec=LLMClient)
192
- mock_client.chat = AsyncMock(side_effect=[glob_call, read_call, final])
196
+ mock_client.chat = AsyncMock(side_effect=[_resp(glob_call), _resp(read_call), _resp(final)])
193
197
 
194
198
  engine = _make_engine(godot_project, mock_client)
195
199
  result = await engine.submit("List all scripts and show me the player")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes