cobalt-cli-linux 0.1.0__tar.gz → 0.1.1__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 (22) hide show
  1. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/PKG-INFO +3 -1
  2. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/README.md +2 -0
  3. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/pyproject.toml +4 -1
  4. cobalt_cli_linux-0.1.1/src/cobalt_cli_linux/ASCII.txt +5 -0
  5. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/agent.py +50 -20
  6. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/cli.py +2 -6
  7. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/groq_client.py +31 -28
  8. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/tui.py +28 -25
  9. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux.egg-info/PKG-INFO +3 -1
  10. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux.egg-info/SOURCES.txt +1 -0
  11. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/tests/test_cli.py +93 -4
  12. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/setup.cfg +0 -0
  13. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/__init__.py +0 -0
  14. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/__main__.py +0 -0
  15. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/config.py +0 -0
  16. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/deepseek_client.py +0 -0
  17. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/executor.py +0 -0
  18. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux/history.py +0 -0
  19. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux.egg-info/dependency_links.txt +0 -0
  20. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux.egg-info/entry_points.txt +0 -0
  21. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux.egg-info/requires.txt +0 -0
  22. {cobalt_cli_linux-0.1.0 → cobalt_cli_linux-0.1.1}/src/cobalt_cli_linux.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cobalt-cli-linux
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -35,6 +35,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
35
35
  - File read/write operations for scriptable agent workflows
36
36
  - PyPI-ready package metadata and console script entrypoint
37
37
 
38
+ When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
39
+
38
40
  ## Installation
39
41
 
40
42
  ```bash
@@ -10,6 +10,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
10
10
  - File read/write operations for scriptable agent workflows
11
11
  - PyPI-ready package metadata and console script entrypoint
12
12
 
13
+ When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
14
+
13
15
  ## Installation
14
16
 
15
17
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cobalt-cli-linux"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -45,5 +45,8 @@ package-dir = {"" = "src"}
45
45
  [tool.setuptools.packages.find]
46
46
  where = ["src"]
47
47
 
48
+ [tool.setuptools.package-data]
49
+ cobalt_cli_linux = ["ASCII.txt"]
50
+
48
51
  [tool.pytest.ini_options]
49
52
  pythonpath = ["src"]
@@ -0,0 +1,5 @@
1
+ ____ ___ ____ _ _ _____ ____ _ ___
2
+ / ___/ _ \| __ ) / \ | | |_ _| / ___| | |_ _|
3
+ | | | | | | _ \ / _ \ | | | | | | | | | |
4
+ | |__| |_| | |_) / ___ \| |___| | | |___| |___ | |
5
+ \____\___/|____/_/ \_\_____|_| \____|_____|___|
@@ -56,24 +56,49 @@ class CobaltAgent:
56
56
  path = Path(path_text.strip())
57
57
  path.parent.mkdir(parents=True, exist_ok=True)
58
58
  path.write_text(content, encoding="utf-8")
59
+ if not path.is_file() or path.read_text(encoding="utf-8") != content:
60
+ raise OSError(f"File verification failed for {path}")
59
61
  return f"Wrote {path}"
60
62
  raise ValueError(f"Unsupported tool: {tool_name}")
61
63
 
62
64
  def _extract_tools(self, response: str) -> list[tuple[str, str]]:
63
65
  tools: list[tuple[str, str]] = []
64
- for line in response.splitlines():
65
- match = re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*(.*)$", line.strip())
66
- if match:
67
- tools.append((match.group(1), match.group(2)))
66
+ lines = response.splitlines()
67
+ index = 0
68
+ while index < len(lines):
69
+ match = re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*(.*)$", lines[index].strip())
70
+ if not match:
71
+ index += 1
68
72
  continue
69
- if "WRITE_FILE:" in response:
70
- match = re.search(r"WRITE_FILE:\s*(.+?)\n(.+)", response, re.DOTALL)
71
- if match:
72
- path = match.group(1).strip()
73
- content = match.group(2)
74
- tools.append(("WRITE_FILE", f"{path}\n{content}"))
73
+
74
+ tool_name, payload = match.groups()
75
+ if tool_name == "WRITE_FILE":
76
+ content_lines: list[str] = []
77
+ index += 1
78
+ while index < len(lines) and not re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*", lines[index].strip()):
79
+ content_lines.append(lines[index])
80
+ index += 1
81
+ tools.append((tool_name, f"{payload}\n{chr(10).join(content_lines)}"))
82
+ continue
83
+
84
+ tools.append((tool_name, payload))
85
+ index += 1
75
86
  return tools
76
87
 
88
+ def _tool_results(self, response: str) -> list[str]:
89
+ results: list[str] = []
90
+ for tool_name, payload in self._extract_tools(response):
91
+ try:
92
+ result = self._execute_tool(tool_name, payload)
93
+ results.append(f"{tool_name} result:\n{result}")
94
+ except (OSError, ValueError, RuntimeError) as exc: # pragma: no cover - defensive path
95
+ results.append(f"{tool_name} error:\n{exc}")
96
+ return results
97
+
98
+ def _follow_up_messages(self, tool_results: list[str]) -> list[dict[str, str]]:
99
+ final_prompt = "Here are the tool result(s) from my previous action:\n\n" + "\n\n".join(tool_results) + "\n\nPlease provide the final user-facing response based on these results."
100
+ return [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20) + [{"role": "user", "content": final_prompt}]
101
+
77
102
  def process(self, prompt: str) -> str:
78
103
  if not self.settings.api_key_configured:
79
104
  raise RuntimeError("Missing Groq API key. Use the built-in key provided by the package, or set GROQ_API_KEY in a local .env file before running the CLI.")
@@ -87,17 +112,9 @@ class CobaltAgent:
87
112
  self.history.add("assistant", response)
88
113
  return response
89
114
 
90
- tool_results: list[str] = []
91
- for tool_name, payload in tool_calls:
92
- try:
93
- result = self._execute_tool(tool_name, payload)
94
- tool_results.append(f"{tool_name} result:\n{result}")
95
- except Exception as exc: # pragma: no cover - defensive path
96
- tool_results.append(f"{tool_name} error:\n{exc}")
97
-
98
- final_prompt = "Here are the tool result(s) from my previous action:\n\n" + "\n\n".join(tool_results) + "\n\nPlease provide the final user-facing response based on these results."
115
+ tool_results = self._tool_results(response)
99
116
  follow_up = self.client.chat_completion(
100
- [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20) + [{"role": "user", "content": final_prompt}],
117
+ self._follow_up_messages(tool_results),
101
118
  temperature=self.settings.temperature,
102
119
  max_tokens=self.settings.max_tokens,
103
120
  )
@@ -128,6 +145,19 @@ class CobaltAgent:
128
145
  if on_chunk is not None:
129
146
  on_chunk(chunk)
130
147
 
148
+ tool_results = self._tool_results(buffer)
149
+ if tool_results:
150
+ follow_up_buffer = ""
151
+ for chunk in self.client.chat_completion_stream(
152
+ self._follow_up_messages(tool_results),
153
+ temperature=self.settings.temperature,
154
+ max_tokens=self.settings.max_tokens,
155
+ ):
156
+ follow_up_buffer += chunk
157
+ if on_chunk is not None:
158
+ on_chunk(chunk)
159
+ buffer = follow_up_buffer
160
+
131
161
  if self.history.messages and self.history.messages[-1].get("role") == "assistant":
132
162
  self.history.messages[-1]["content"] = buffer
133
163
  self.history.save()
@@ -35,20 +35,16 @@ def main(argv: list[str] | None = None) -> int:
35
35
  max_tokens=args.max_tokens if args.max_tokens is not None else 1024,
36
36
  )
37
37
 
38
- if args.interactive:
38
+ if args.interactive or not args.prompt:
39
39
  return CobaltTUI(settings).run()
40
40
 
41
41
  prompt = " ".join(args.prompt)
42
- if not prompt:
43
- print("Please provide a prompt or use --interactive for the full-screen UI.", file=sys.stderr)
44
- return 2
45
-
46
42
  try:
47
43
  agent = CobaltAgent(settings=settings)
48
44
  response = agent.process(prompt)
49
45
  print(response)
50
46
  return 0
51
- except Exception as exc:
47
+ except (OSError, RuntimeError, ValueError) as exc:
52
48
  print(f"Error: {exc}", file=sys.stderr)
53
49
  return 1
54
50
 
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
- from typing import Iterator
4
+ from collections.abc import Iterator
5
+ from typing import ClassVar
5
6
 
6
7
  import httpx
7
8
 
@@ -11,7 +12,7 @@ class GroqAPIError(RuntimeError):
11
12
 
12
13
 
13
14
  class GroqClient:
14
- DEFAULT_MODELS = [
15
+ DEFAULT_MODELS: ClassVar[list[str]] = [
15
16
  "llama-3.3-70b-versatile",
16
17
  "llama-3.1-8b-instant",
17
18
  "llama-3.3-8b",
@@ -128,29 +129,31 @@ class GroqClient:
128
129
  "Content-Type": "application/json",
129
130
  }
130
131
 
131
- with httpx.Client(timeout=60.0) as client:
132
- with client.stream("POST", f"{self.base_url}/chat/completions", headers=headers, json=payload) as response:
133
- if response.status_code != 200:
134
- try:
135
- detail = response.json()
136
- except ValueError:
137
- detail = response.text
138
- raise GroqAPIError(f"Groq API request failed ({response.status_code}): {detail}")
139
-
140
- for line in response.iter_lines():
141
- if not line or not line.startswith("data:"):
142
- continue
143
- data = line[5:].strip()
144
- if data == "[DONE]":
145
- break
146
- try:
147
- event = json.loads(data)
148
- except json.JSONDecodeError:
149
- continue
150
- choices = event.get("choices", [])
151
- if not choices:
152
- continue
153
- delta = choices[0].get("delta", {})
154
- content = delta.get("content")
155
- if isinstance(content, str):
156
- yield content
132
+ with httpx.Client(timeout=60.0) as client, client.stream(
133
+ "POST", f"{self.base_url}/chat/completions", headers=headers, json=payload
134
+ ) as response:
135
+ if response.status_code != 200:
136
+ response.read()
137
+ try:
138
+ detail = response.json()
139
+ except ValueError:
140
+ detail = response.text
141
+ raise GroqAPIError(f"Groq API request failed ({response.status_code}): {detail}")
142
+
143
+ for line in response.iter_lines():
144
+ if not line or not line.startswith("data:"):
145
+ continue
146
+ data = line[5:].strip()
147
+ if data == "[DONE]":
148
+ break
149
+ try:
150
+ event = json.loads(data)
151
+ except json.JSONDecodeError:
152
+ continue
153
+ choices = event.get("choices", [])
154
+ if not choices:
155
+ continue
156
+ delta = choices[0].get("delta", {})
157
+ content = delta.get("content")
158
+ if isinstance(content, str):
159
+ yield content
@@ -6,7 +6,7 @@ import textwrap
6
6
  from pathlib import Path
7
7
 
8
8
  from .agent import CobaltAgent
9
- from .history import ChatStore, ConversationHistory
9
+ from .history import ChatStore
10
10
 
11
11
 
12
12
  class CobaltTUI:
@@ -25,7 +25,9 @@ class CobaltTUI:
25
25
  self.ascii_lines = self._load_ascii_art()
26
26
 
27
27
  def _load_ascii_art(self) -> list[str]:
28
- ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
28
+ ascii_path = Path(__file__).with_name("ASCII.txt")
29
+ if not ascii_path.exists():
30
+ ascii_path = Path(__file__).resolve().parents[2] / "ASCII.txt"
29
31
  if not ascii_path.exists():
30
32
  return ["COBALT"]
31
33
  return [line.rstrip() for line in ascii_path.read_text(encoding="utf-8").splitlines() if line.strip()]
@@ -56,10 +58,8 @@ class CobaltTUI:
56
58
  title = " ".join(words[:4]).strip()
57
59
  return title.title() if title else "New chat"
58
60
 
59
- def _should_handle_shortcut(self, ch: int, input_text: str) -> bool:
60
- if input_text.strip():
61
- return False
62
- return ch in (ord("n"), ord("d"))
61
+ def _should_handle_shortcut(self, ch: int) -> bool:
62
+ return ch in (14, 4)
63
63
 
64
64
  def _compose_lines(self, text: str, width: int) -> list[str]:
65
65
  lines: list[str] = []
@@ -67,8 +67,7 @@ class CobaltTUI:
67
67
  if not paragraph:
68
68
  lines.append("")
69
69
  continue
70
- for wrapped in textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False):
71
- lines.append(wrapped)
70
+ lines.extend(textwrap.wrap(paragraph, width=max(15, width), break_long_words=False, break_on_hyphens=False))
72
71
  return lines
73
72
 
74
73
  def _draw_box(self, stdscr, top: int, left: int, height: int, width: int, title: str | None = None) -> None:
@@ -89,6 +88,9 @@ class CobaltTUI:
89
88
  stdscr.bkgd(" ", curses.color_pair(0))
90
89
  height, width = stdscr.getmaxyx()
91
90
 
91
+ header = " Cobalt Code | terminal agent "
92
+ stdscr.addstr(0, 1, header[: max(0, width - 2)], curses.A_BOLD)
93
+
92
94
  sidebar_w = min(26, max(20, width // 4))
93
95
  sidebar_x = 1
94
96
  sidebar_y = 1
@@ -112,7 +114,7 @@ class CobaltTUI:
112
114
  main_w = max(20, width - main_x - 2)
113
115
  main_y = 1
114
116
  main_h = max(8, height - 4)
115
- self._draw_box(stdscr, main_y, main_x, main_h, main_w, " conversation ")
117
+ self._draw_box(stdscr, main_y, main_x, main_h, main_w, f" {self.history.title or 'conversation'} ")
116
118
 
117
119
  if not self.history.messages:
118
120
  for idx, line in enumerate(self.ascii_lines):
@@ -128,7 +130,7 @@ class CobaltTUI:
128
130
  for message in self.history.latest(18):
129
131
  role = message.get("role", "user").upper()
130
132
  content = message.get("content", "")
131
- prefix = f"[{role}] "
133
+ prefix = f"{role.lower()} "
132
134
  for line in self._compose_lines(content, max(12, main_w - 8 - len(prefix))):
133
135
  rows.append(f"{prefix}{line}" if prefix and line else line)
134
136
  prefix = ""
@@ -144,9 +146,10 @@ class CobaltTUI:
144
146
  prompt_x = main_x + max(0, (main_w - len(prompt)) // 2)
145
147
  stdscr.addstr(prompt_y, prompt_x, prompt[: max(0, main_w - 2)], curses.A_REVERSE)
146
148
 
147
- status_line = status[: max(0, width - 1)]
148
- stdscr.addstr(height - 1, 0, status_line)
149
- stdscr.clrtoeol()
149
+ if status:
150
+ status_line = status[: max(0, width - 1)]
151
+ stdscr.addstr(height - 1, 0, status_line)
152
+ stdscr.clrtoeol()
150
153
  stdscr.refresh()
151
154
 
152
155
  def _stream_response(self, stdscr, input_text: str) -> str:
@@ -158,7 +161,7 @@ class CobaltTUI:
158
161
  if self.history.messages and self.history.messages[-1]["role"] == "assistant":
159
162
  self.history.messages[-1]["content"] = buffer
160
163
  self.history.save()
161
- self._render(stdscr, input_text, "Streaming...")
164
+ self._render(stdscr, input_text, "")
162
165
 
163
166
  agent = CobaltAgent(settings=self.settings)
164
167
  agent.history = self.history
@@ -199,7 +202,7 @@ class CobaltTUI:
199
202
  curses.noecho()
200
203
  curses.mousemask(curses.BUTTON1_PRESSED | curses.BUTTON1_RELEASED | curses.BUTTON1_CLICKED)
201
204
  input_text = ""
202
- status = "Ctrl+C or q to quit • Enter to send • n new chat • d delete chat"
205
+ status = ""
203
206
 
204
207
  while True:
205
208
  self._render(stdscr, input_text, status)
@@ -207,16 +210,15 @@ class CobaltTUI:
207
210
 
208
211
  if ch == curses.KEY_MOUSE:
209
212
  _, x, y, _, button_state = curses.getmouse()
210
- if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED):
211
- if self._handle_mouse_click(x, y):
212
- status = "Chat selected"
213
- input_text = ""
213
+ if button_state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED | curses.BUTTON1_RELEASED) and self._handle_mouse_click(x, y):
214
+ status = "Chat selected"
215
+ input_text = ""
214
216
  continue
215
217
 
216
- if ch in (ord("q"), 3):
218
+ if ch == 3:
217
219
  break
218
- if ch in (ord("n"), ord("d")) and self._should_handle_shortcut(ch, input_text):
219
- if ch == ord("n"):
220
+ if self._should_handle_shortcut(ch):
221
+ if ch == 14:
220
222
  new_chat = self.chat_store.new_chat(title="New chat")
221
223
  self.current_chat = new_chat
222
224
  self.history = self.current_chat
@@ -226,7 +228,7 @@ class CobaltTUI:
226
228
  status = "New chat started"
227
229
  continue
228
230
 
229
- if ch == ord("d") and self.selected_chat_path:
231
+ if ch == 4 and self.selected_chat_path:
230
232
  target = Path(self.selected_chat_path)
231
233
  if target.exists():
232
234
  self.chat_store.delete_chat(target)
@@ -250,8 +252,9 @@ class CobaltTUI:
250
252
  self.history.save()
251
253
  self._stream_response(stdscr, input_text)
252
254
  status = "Response received"
253
- except Exception as exc:
254
- status = f"Error: {exc}"
255
+ except (OSError, RuntimeError, ValueError) as exc:
256
+ self.history.add("assistant", f"Error: {exc}")
257
+ status = ""
255
258
  input_text = ""
256
259
  continue
257
260
  if ch in (curses.KEY_BACKSPACE, 127, 8):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cobalt-cli-linux
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
5
  Author: Cobalt
6
6
  License-Expression: MIT
@@ -35,6 +35,8 @@ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It ca
35
35
  - File read/write operations for scriptable agent workflows
36
36
  - PyPI-ready package metadata and console script entrypoint
37
37
 
38
+ When the agent emits a `WRITE_FILE` action, Cobalt creates the requested file using UTF-8 and verifies that the file exists with the expected contents before reporting success.
39
+
38
40
  ## Installation
39
41
 
40
42
  ```bash
@@ -1,5 +1,6 @@
1
1
  README.md
2
2
  pyproject.toml
3
+ src/cobalt_cli_linux/ASCII.txt
3
4
  src/cobalt_cli_linux/__init__.py
4
5
  src/cobalt_cli_linux/__main__.py
5
6
  src/cobalt_cli_linux/agent.py
@@ -1,5 +1,7 @@
1
1
  from pathlib import Path
2
2
 
3
+ from cobalt_cli_linux import cli
4
+ from cobalt_cli_linux.agent import CobaltAgent
3
5
  from cobalt_cli_linux.config import Settings
4
6
  from cobalt_cli_linux.executor import ShellExecutor
5
7
  from cobalt_cli_linux.groq_client import GroqClient
@@ -47,10 +49,97 @@ def test_chat_store_delete_chat(tmp_path: Path) -> None:
47
49
  def test_tui_shortcuts_ignore_typed_letters() -> None:
48
50
  tui = CobaltTUI(settings=Settings())
49
51
 
50
- assert tui._should_handle_shortcut(ord("n"), "") is True
51
- assert tui._should_handle_shortcut(ord("d"), "") is True
52
- assert tui._should_handle_shortcut(ord("n"), "note") is False
53
- assert tui._should_handle_shortcut(ord("d"), "delete") is False
52
+ assert tui._should_handle_shortcut(14) is True
53
+ assert tui._should_handle_shortcut(4) is True
54
+ assert tui._should_handle_shortcut(ord("q")) is False
55
+ assert tui._should_handle_shortcut(ord("n")) is False
56
+ assert tui._should_handle_shortcut(ord("d")) is False
57
+
58
+
59
+ def test_q_is_not_a_shortcut_when_typing() -> None:
60
+ tui = CobaltTUI(settings=Settings())
61
+
62
+ assert tui._should_handle_shortcut(ord("q")) is False
63
+
64
+
65
+ def test_no_prompt_launches_tui(monkeypatch) -> None:
66
+ launched = []
67
+
68
+ class FakeTUI:
69
+ def __init__(self, settings):
70
+ launched.append(settings)
71
+
72
+ def run(self):
73
+ return 0
74
+
75
+ monkeypatch.setattr(cli, "CobaltTUI", FakeTUI)
76
+
77
+ assert cli.main([]) == 0
78
+ assert launched
79
+
80
+
81
+ def test_stream_error_reads_response_before_inspecting_body(monkeypatch) -> None:
82
+ class FakeResponse:
83
+ status_code = 401
84
+ text = "unauthorized"
85
+ was_read = False
86
+
87
+ def read(self):
88
+ self.was_read = True
89
+
90
+ def json(self):
91
+ assert self.was_read is True
92
+ return {"error": {"message": "unauthorized"}}
93
+
94
+ def __enter__(self):
95
+ return self
96
+
97
+ def __exit__(self, exc_type, exc, tb):
98
+ return False
99
+
100
+ class FakeStreamClient:
101
+ def __init__(self, timeout=None):
102
+ self.response = FakeResponse()
103
+
104
+ def __enter__(self):
105
+ return self
106
+
107
+ def __exit__(self, exc_type, exc, tb):
108
+ return False
109
+
110
+ def get(self, url, headers=None):
111
+ return type("Response", (), {"status_code": 200, "json": lambda self: {"data": []}})()
112
+
113
+ def stream(self, method, url, headers=None, json=None):
114
+ return self.response
115
+
116
+ monkeypatch.setattr("cobalt_cli_linux.groq_client.httpx.Client", FakeStreamClient)
117
+ client = GroqClient("test-key", model="llama-3.1-8b-instant")
118
+
119
+ try:
120
+ next(client.chat_completion_stream([{"role": "user", "content": "hello"}]))
121
+ except RuntimeError as exc:
122
+ assert "401" in str(exc)
123
+ else:
124
+ raise AssertionError("Expected streamed API error")
125
+
126
+
127
+ def test_write_file_tool_creates_and_verifies_file(tmp_path: Path) -> None:
128
+ agent = CobaltAgent(settings=Settings(), history=ConversationHistory(tmp_path / "history.json"))
129
+ target = tmp_path / "test.txt"
130
+
131
+ result = agent._execute_tool("WRITE_FILE", f"{target}\nhello")
132
+
133
+ assert result == f"Wrote {target}"
134
+ assert target.read_text(encoding="utf-8") == "hello"
135
+
136
+
137
+ def test_write_file_directive_is_parsed_once() -> None:
138
+ agent = CobaltAgent(settings=Settings())
139
+
140
+ tools = agent._extract_tools("WRITE_FILE: test.txt\nhello\nworld")
141
+
142
+ assert tools == [("WRITE_FILE", "test.txt\nhello\nworld")]
54
143
 
55
144
 
56
145
  def test_shell_executor_runs_command() -> None: