codeer-cli 0.1.8__tar.gz → 0.1.9__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.
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/API_REFERENCE.md +16 -6
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/PKG-INFO +12 -1
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/README.md +11 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/pyproject.toml +1 -1
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/__init__.py +2 -2
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/chats.py +2 -2
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/cli.py +1 -1
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/client.py +96 -24
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/eval_cmd.py +10 -16
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/history.py +125 -13
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/eval_.py +5 -6
- codeer_cli-0.1.9/tests/test_client_transport.py +84 -0
- codeer_cli-0.1.9/tests/test_eval_labels.py +238 -0
- codeer_cli-0.1.9/tests/test_history_send.py +159 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/uv.lock +1 -1
- codeer_cli-0.1.8/tests/test_eval_labels.py +0 -101
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/.gitignore +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/_validate.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/agents.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/__init__.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/_util.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/agent.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/check.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/kb.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/commands/profile.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/constants.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/histories.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/kb.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/src/codeer_cli/parse.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/tests/test_eval_pairs.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/tests/test_kb_nodes.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/tests/test_kb_ranges.py +0 -0
- {codeer_cli-0.1.8 → codeer_cli-0.1.9}/tests/test_util.py +0 -0
|
@@ -149,10 +149,10 @@ for the apply → test → publish workflow. Pass the draft `AgentHistory.id` fr
|
|
|
149
149
|
|
|
150
150
|
| Method & path | Purpose |
|
|
151
151
|
| --- | --- |
|
|
152
|
-
| `GET /eval/
|
|
153
|
-
| `POST /eval/
|
|
154
|
-
| `PUT /eval/case-labels/{label_id}` | Update eval case label
|
|
155
|
-
| `DELETE /eval/case-labels/{label_id}` | Delete eval case label and clear associations |
|
|
152
|
+
| `GET /external/eval/case-labels` | List reusable eval case labels in the API key workspace |
|
|
153
|
+
| `POST /external/eval/case-labels` | Create reusable eval case label (`name`, `color?`) in the API key workspace |
|
|
154
|
+
| `PUT /external/eval/case-labels/{label_id}` | Update an eval case label in the API key workspace |
|
|
155
|
+
| `DELETE /external/eval/case-labels/{label_id}` | Delete an eval case label and clear associations |
|
|
156
156
|
| `POST /eval/cases` | Create case (`input`, `expected_output?`, `rubric?`, `label_ids?`); rubric = user-docs "Standard" |
|
|
157
157
|
| `GET /eval/agents/{agent_id}/cases` | List cases for an agent |
|
|
158
158
|
| `GET /eval/cases/{case_id}` | Read one |
|
|
@@ -171,8 +171,11 @@ for the apply → test → publish workflow. Pass the draft `AgentHistory.id` fr
|
|
|
171
171
|
| `POST /eval/rubric` | Set/override the rubric for one (case, evaluator); also creates assignment |
|
|
172
172
|
| `POST /eval/rubrics/batch` | **Read** rubrics for a batch of (case, evaluator) pairs |
|
|
173
173
|
|
|
174
|
-
Eval case labels are workspace-scoped reusable objects. The
|
|
175
|
-
|
|
174
|
+
Eval case labels are workspace-scoped reusable objects. The external label
|
|
175
|
+
endpoints infer the workspace from the API key and do not accept a workspace
|
|
176
|
+
override; switch CLI profiles to operate on another workspace. The case
|
|
177
|
+
create/update payload uses `label_ids` (stringified label IDs), not freeform
|
|
178
|
+
label names:
|
|
176
179
|
|
|
177
180
|
```json
|
|
178
181
|
{
|
|
@@ -247,12 +250,19 @@ the public CLI.
|
|
|
247
250
|
|
|
248
251
|
| Method & path | Purpose |
|
|
249
252
|
| --- | --- |
|
|
253
|
+
| `POST /chats` | Create a persisted history using an agent's current published version |
|
|
254
|
+
| `POST /chats/{id}/messages` | Append a turn to an existing persisted history using the current published version |
|
|
250
255
|
| `GET /histories?agent_id=X&feedback_filter=improve_feedback&external_user_id=…` | List conversations with filters |
|
|
251
256
|
| `GET /histories/{id}` | Read one history's metadata |
|
|
252
257
|
| `GET /histories/{id}/conversations` | Full conversation turns incl. tool calls |
|
|
253
258
|
| `POST /histories/{hid}/conversations/{cid}/feedbacks` | Leave freeform improvement feedback |
|
|
254
259
|
| `POST /histories/{hid}/conversations/{cid}/score` | Numeric score |
|
|
255
260
|
|
|
261
|
+
The CLI exposes the first two operations as `codeer history create` and
|
|
262
|
+
`codeer history send`. Non-streaming message requests default to a 120-second
|
|
263
|
+
per-message timeout. A timeout has an uncertain write outcome, so read the
|
|
264
|
+
history before retrying to avoid duplicate turns.
|
|
265
|
+
|
|
256
266
|
`feedback_filter` accepts the `FeedbackFilterType` enum values:
|
|
257
267
|
`no_feedback`, `with_feedback`, `helpful_feedback`, `improve_feedback`.
|
|
258
268
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codeer-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.9
|
|
4
4
|
Summary: Command line tools for managing Codeer agents over the Codeer API.
|
|
5
5
|
Project-URL: Homepage, https://www.codeer.ai
|
|
6
6
|
Author: Codeer.AI
|
|
@@ -143,9 +143,20 @@ Use this pattern during agent lifecycle work:
|
|
|
143
143
|
```bash
|
|
144
144
|
codeer agent list
|
|
145
145
|
codeer history list --agent <agent-id> --limit 50
|
|
146
|
+
codeer history create --agent <agent-id> --message "Review this plan" --timeout 120
|
|
147
|
+
codeer history send <history-id> --message "Use the recommended options" --timeout 120
|
|
146
148
|
codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
|
|
147
149
|
```
|
|
148
150
|
|
|
151
|
+
`history create` and `history send` use the agent's current published version.
|
|
152
|
+
Their per-message timeout defaults to 120 seconds. If a write request times
|
|
153
|
+
out, inspect the history before retrying: the server may have completed the
|
|
154
|
+
turn after the client stopped waiting.
|
|
155
|
+
|
|
156
|
+
Eval case label commands always operate on the active API-key workspace. They
|
|
157
|
+
do not accept a workspace override; switch CLI profiles to target another
|
|
158
|
+
workspace.
|
|
159
|
+
|
|
149
160
|
Flags:
|
|
150
161
|
|
|
151
162
|
- `--full` prints bounded extra detail for human inspection. It is still
|
|
@@ -125,9 +125,20 @@ Use this pattern during agent lifecycle work:
|
|
|
125
125
|
```bash
|
|
126
126
|
codeer agent list
|
|
127
127
|
codeer history list --agent <agent-id> --limit 50
|
|
128
|
+
codeer history create --agent <agent-id> --message "Review this plan" --timeout 120
|
|
129
|
+
codeer history send <history-id> --message "Use the recommended options" --timeout 120
|
|
128
130
|
codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
|
|
129
131
|
```
|
|
130
132
|
|
|
133
|
+
`history create` and `history send` use the agent's current published version.
|
|
134
|
+
Their per-message timeout defaults to 120 seconds. If a write request times
|
|
135
|
+
out, inspect the history before retrying: the server may have completed the
|
|
136
|
+
turn after the client stopped waiting.
|
|
137
|
+
|
|
138
|
+
Eval case label commands always operate on the active API-key workspace. They
|
|
139
|
+
do not accept a workspace override; switch CLI profiles to target another
|
|
140
|
+
workspace.
|
|
141
|
+
|
|
131
142
|
Flags:
|
|
132
143
|
|
|
133
144
|
- `--full` prints bounded extra detail for human inspection. It is still
|
|
@@ -19,7 +19,7 @@ workspace-local dotenv files or credential files.
|
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
21
|
from ._validate import ToolValidationError
|
|
22
|
-
from .client import AuthError, CodeerClient, CodeerError
|
|
22
|
+
from .client import AuthError, CodeerClient, CodeerError, TransportError
|
|
23
23
|
from .parse import (
|
|
24
24
|
AgentSummary,
|
|
25
25
|
ConversationTurn,
|
|
@@ -43,7 +43,7 @@ from .parse import (
|
|
|
43
43
|
)
|
|
44
44
|
|
|
45
45
|
__all__ = [
|
|
46
|
-
"CodeerClient", "CodeerError", "AuthError", "ToolValidationError",
|
|
46
|
+
"CodeerClient", "CodeerError", "TransportError", "AuthError", "ToolValidationError",
|
|
47
47
|
# parsers
|
|
48
48
|
"AgentSummary", "ConversationTurn", "EvalResultSummary", "HistorySummary",
|
|
49
49
|
"KBNode", "ToolCall", "EvalToolCall",
|
|
@@ -36,6 +36,7 @@ def send_published_agent_message(
|
|
|
36
36
|
external_user_id: Optional[str] = None,
|
|
37
37
|
attachment_ids: Optional[List[str]] = None,
|
|
38
38
|
stream: bool = False,
|
|
39
|
+
timeout: Optional[float] = None,
|
|
39
40
|
) -> Iterator[dict] | dict:
|
|
40
41
|
"""Send a user message through the API-key external chat flow.
|
|
41
42
|
|
|
@@ -51,7 +52,7 @@ def send_published_agent_message(
|
|
|
51
52
|
path = f"/chats/{chat_id}/messages"
|
|
52
53
|
if stream:
|
|
53
54
|
return client.stream_sse("POST", path, json=body)
|
|
54
|
-
return client.post(path, json=body)
|
|
55
|
+
return client.post(path, json=body, timeout=timeout)
|
|
55
56
|
|
|
56
57
|
|
|
57
58
|
def send_message(
|
|
@@ -84,4 +85,3 @@ def list_messages(client: CodeerClient, chat_id: int) -> list[dict]:
|
|
|
84
85
|
|
|
85
86
|
def list_chats(client: CodeerClient) -> list[dict]:
|
|
86
87
|
return client.get("/chats")
|
|
87
|
-
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
codeer agent list|get|apply|diff|versions
|
|
5
5
|
codeer kb list|files|upload|node-rename|node-delete|faq-list|faq-get|faq-create|faq-update|faq-delete
|
|
6
6
|
codeer eval list|label-list|label-create|label-update|label-delete|case-update|case-delete|evaluators|evaluator-create|evaluator-update|run|export|reconcile|cases-apply|rubrics|rubrics-apply
|
|
7
|
-
codeer history list|get|conversations|negative-feedback
|
|
7
|
+
codeer history list|get|conversations|negative-feedback|create|send
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -37,6 +37,16 @@ class ScopeResolutionError(CodeerError):
|
|
|
37
37
|
"""Raised when workspace or organization scope cannot be inferred."""
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
class TransportError(CodeerError):
|
|
41
|
+
"""Raised when an HTTP request fails before a response is available."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, body: Any = None):
|
|
44
|
+
RuntimeError.__init__(self, message)
|
|
45
|
+
self.status = 0
|
|
46
|
+
self.message = message
|
|
47
|
+
self.body = body
|
|
48
|
+
|
|
49
|
+
|
|
40
50
|
@dataclass
|
|
41
51
|
class CodeerClient:
|
|
42
52
|
"""Thin wrapper around httpx.Client with Codeer API-key auth.
|
|
@@ -148,9 +158,32 @@ class CodeerClient:
|
|
|
148
158
|
json: Any = None,
|
|
149
159
|
files: Any = None,
|
|
150
160
|
data: Any = None,
|
|
161
|
+
timeout: Optional[float] = None,
|
|
151
162
|
) -> Any:
|
|
152
163
|
url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
|
|
153
|
-
|
|
164
|
+
request_kwargs: dict[str, Any] = {}
|
|
165
|
+
if timeout is not None:
|
|
166
|
+
request_kwargs["timeout"] = timeout
|
|
167
|
+
method_upper = method.upper()
|
|
168
|
+
try:
|
|
169
|
+
r = self._client.request(
|
|
170
|
+
method_upper,
|
|
171
|
+
url,
|
|
172
|
+
params=params,
|
|
173
|
+
json=json,
|
|
174
|
+
files=files,
|
|
175
|
+
data=data,
|
|
176
|
+
**request_kwargs,
|
|
177
|
+
)
|
|
178
|
+
except httpx.TimeoutException as exc:
|
|
179
|
+
raise self._transport_error(
|
|
180
|
+
method_upper,
|
|
181
|
+
path,
|
|
182
|
+
exc,
|
|
183
|
+
timeout_seconds=timeout if timeout is not None else self.timeout,
|
|
184
|
+
) from exc
|
|
185
|
+
except httpx.RequestError as exc:
|
|
186
|
+
raise self._transport_error(method_upper, path, exc) from exc
|
|
154
187
|
return self._parse(r)
|
|
155
188
|
|
|
156
189
|
def get(self, path: str, **kwargs: Any) -> Any:
|
|
@@ -181,29 +214,68 @@ class CodeerClient:
|
|
|
181
214
|
Each event is a dict like ``{"event": "message", "data": <parsed-json-or-str>}``.
|
|
182
215
|
"""
|
|
183
216
|
url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
217
|
+
method_upper = method.upper()
|
|
218
|
+
try:
|
|
219
|
+
with self._client.stream(method_upper, url, params=params, json=json) as r:
|
|
220
|
+
if r.status_code >= 400:
|
|
221
|
+
body = r.read().decode("utf-8", "replace")
|
|
222
|
+
self._raise_for_error(r.status_code, body)
|
|
223
|
+
event = "message"
|
|
224
|
+
buf: list[str] = []
|
|
225
|
+
for line in r.iter_lines():
|
|
226
|
+
if line == "":
|
|
227
|
+
if buf:
|
|
228
|
+
raw = "\n".join(buf)
|
|
229
|
+
yield {"event": event, "data": _maybe_json(raw)}
|
|
230
|
+
buf = []
|
|
231
|
+
event = "message"
|
|
232
|
+
continue
|
|
233
|
+
if line.startswith(":"):
|
|
234
|
+
continue
|
|
235
|
+
if line.startswith("event:"):
|
|
236
|
+
event = line[len("event:"):].strip()
|
|
237
|
+
continue
|
|
238
|
+
if line.startswith("data:"):
|
|
239
|
+
buf.append(line[len("data:"):].lstrip())
|
|
240
|
+
if buf:
|
|
241
|
+
yield {"event": event, "data": _maybe_json("\n".join(buf))}
|
|
242
|
+
except httpx.TimeoutException as exc:
|
|
243
|
+
raise self._transport_error(
|
|
244
|
+
method_upper,
|
|
245
|
+
path,
|
|
246
|
+
exc,
|
|
247
|
+
timeout_seconds=self.timeout,
|
|
248
|
+
) from exc
|
|
249
|
+
except httpx.RequestError as exc:
|
|
250
|
+
raise self._transport_error(method_upper, path, exc) from exc
|
|
251
|
+
|
|
252
|
+
def _transport_error(
|
|
253
|
+
self,
|
|
254
|
+
method: str,
|
|
255
|
+
path: str,
|
|
256
|
+
exc: httpx.RequestError,
|
|
257
|
+
*,
|
|
258
|
+
timeout_seconds: float | None = None,
|
|
259
|
+
) -> TransportError:
|
|
260
|
+
outcome_uncertain = (
|
|
261
|
+
method not in {"GET", "HEAD", "OPTIONS"}
|
|
262
|
+
and not isinstance(exc, httpx.ConnectError)
|
|
263
|
+
)
|
|
264
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
265
|
+
timeout_value = timeout_seconds if timeout_seconds is not None else self.timeout
|
|
266
|
+
message = f"Request timed out after {timeout_value:g}s: {method} {path}."
|
|
267
|
+
if outcome_uncertain:
|
|
268
|
+
message += " The server may have completed the request; inspect current state before retrying."
|
|
269
|
+
else:
|
|
270
|
+
message = f"Request failed: {method} {path}: {exc}"
|
|
271
|
+
return TransportError(
|
|
272
|
+
message,
|
|
273
|
+
{
|
|
274
|
+
"method": method,
|
|
275
|
+
"path": path,
|
|
276
|
+
"outcome_uncertain": outcome_uncertain,
|
|
277
|
+
},
|
|
278
|
+
)
|
|
207
279
|
|
|
208
280
|
def _parse(self, r: httpx.Response) -> Any:
|
|
209
281
|
text = r.text
|
|
@@ -48,14 +48,12 @@ def register(subparsers):
|
|
|
48
48
|
|
|
49
49
|
# codeer eval label-list/create/update/delete
|
|
50
50
|
p = sub.add_parser("label-list", help="List eval case labels in the workspace")
|
|
51
|
-
p.add_argument("--workspace", default=None, help="Workspace UUID (default: active API-key workspace)")
|
|
52
51
|
p.add_argument("--out", default=None)
|
|
53
52
|
p.set_defaults(func=run_label_list)
|
|
54
53
|
|
|
55
54
|
p = sub.add_parser("label-create", help="Create an eval case label; run --dry-run first")
|
|
56
55
|
p.add_argument("--name", required=True)
|
|
57
56
|
p.add_argument("--color", default=None, help="Hex color like #0969da (default: server default)")
|
|
58
|
-
p.add_argument("--workspace", default=None, help="Workspace UUID (default: active API-key workspace)")
|
|
59
57
|
p.add_argument("--dry-run", action="store_true")
|
|
60
58
|
p.add_argument("--out", default=None)
|
|
61
59
|
p.set_defaults(func=run_label_create)
|
|
@@ -288,9 +286,7 @@ def run_list(args, client) -> int:
|
|
|
288
286
|
# eval case labels
|
|
289
287
|
# ---------------------------------------------------------------------------
|
|
290
288
|
|
|
291
|
-
def
|
|
292
|
-
if workspace_id:
|
|
293
|
-
return workspace_id
|
|
289
|
+
def _active_workspace(client) -> str:
|
|
294
290
|
ws, _ = client.resolve_scope()
|
|
295
291
|
return ws
|
|
296
292
|
|
|
@@ -305,8 +301,8 @@ def _label_summary(label: dict) -> dict:
|
|
|
305
301
|
|
|
306
302
|
|
|
307
303
|
def run_label_list(args, client) -> int:
|
|
308
|
-
workspace_id =
|
|
309
|
-
labels = eval_mod.list_case_labels(client
|
|
304
|
+
workspace_id = _active_workspace(client)
|
|
305
|
+
labels = eval_mod.list_case_labels(client)
|
|
310
306
|
out = {
|
|
311
307
|
"workspace_id": workspace_id,
|
|
312
308
|
"label_count": len(labels),
|
|
@@ -318,13 +314,13 @@ def run_label_list(args, client) -> int:
|
|
|
318
314
|
|
|
319
315
|
|
|
320
316
|
def run_label_create(args, client) -> int:
|
|
321
|
-
workspace_id =
|
|
317
|
+
workspace_id = _active_workspace(client)
|
|
322
318
|
if args.dry_run:
|
|
323
319
|
out = {
|
|
324
320
|
"dry_run": True,
|
|
325
321
|
"operation": "label_create",
|
|
326
322
|
"method": "POST",
|
|
327
|
-
"path":
|
|
323
|
+
"path": "/external/eval/case-labels",
|
|
328
324
|
"workspace_id": workspace_id,
|
|
329
325
|
"name": args.name,
|
|
330
326
|
"color": args.color,
|
|
@@ -335,9 +331,7 @@ def run_label_create(args, client) -> int:
|
|
|
335
331
|
write_json(args.out, out)
|
|
336
332
|
return 0
|
|
337
333
|
|
|
338
|
-
label = eval_mod.create_case_label(
|
|
339
|
-
client, workspace_id=workspace_id, name=args.name, color=args.color
|
|
340
|
-
)
|
|
334
|
+
label = eval_mod.create_case_label(client, name=args.name, color=args.color)
|
|
341
335
|
out = _label_summary(strip_noisy_fields(label))
|
|
342
336
|
print_json(out)
|
|
343
337
|
write_json(args.out, out)
|
|
@@ -354,7 +348,7 @@ def run_label_update(args, client) -> int:
|
|
|
354
348
|
"dry_run": True,
|
|
355
349
|
"operation": "label_update",
|
|
356
350
|
"method": "PUT",
|
|
357
|
-
"path": f"/eval/case-labels/{args.label_id}",
|
|
351
|
+
"path": f"/external/eval/case-labels/{args.label_id}",
|
|
358
352
|
"label_id": args.label_id,
|
|
359
353
|
"updates": {"name": args.name, "color": args.color},
|
|
360
354
|
"would_write_server_state": True,
|
|
@@ -379,7 +373,7 @@ def run_label_delete(args, client) -> int:
|
|
|
379
373
|
"dry_run": True,
|
|
380
374
|
"operation": "label_delete",
|
|
381
375
|
"method": "DELETE",
|
|
382
|
-
"path": f"/eval/case-labels/{args.label_id}",
|
|
376
|
+
"path": f"/external/eval/case-labels/{args.label_id}",
|
|
383
377
|
"label_id": args.label_id,
|
|
384
378
|
"would_write_server_state": True,
|
|
385
379
|
"next_step": "Review this summary, then rerun without --dry-run after approval.",
|
|
@@ -1384,7 +1378,7 @@ def run_cases_apply(args, client) -> int:
|
|
|
1384
1378
|
if manifest_label_names:
|
|
1385
1379
|
labels_by_name = {
|
|
1386
1380
|
(label.get("name") or "").casefold(): label
|
|
1387
|
-
for label in eval_mod.list_case_labels(client
|
|
1381
|
+
for label in eval_mod.list_case_labels(client)
|
|
1388
1382
|
if label.get("name")
|
|
1389
1383
|
}
|
|
1390
1384
|
missing_label_names = [
|
|
@@ -1410,7 +1404,7 @@ def run_cases_apply(args, client) -> int:
|
|
|
1410
1404
|
else:
|
|
1411
1405
|
for name in missing_label_names:
|
|
1412
1406
|
log(f"creating label: {name}")
|
|
1413
|
-
label = eval_mod.create_case_label(client,
|
|
1407
|
+
label = eval_mod.create_case_label(client, name=name)
|
|
1414
1408
|
labels_by_name[name.casefold()] = label
|
|
1415
1409
|
created_labels.append(_label_summary(label))
|
|
1416
1410
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
import os
|
|
4
5
|
|
|
5
6
|
from .. import agents as agents_mod
|
|
6
7
|
from .. import chats as chats_mod
|
|
7
8
|
from .. import histories as hist_mod
|
|
9
|
+
from ..client import TransportError
|
|
8
10
|
from ._util import log, print_json, strip_noisy_fields, truncate, write_json
|
|
9
11
|
|
|
10
12
|
|
|
@@ -74,10 +76,27 @@ def register(subparsers):
|
|
|
74
76
|
p.add_argument("--user", default=None, help="external_user_id to associate with the history")
|
|
75
77
|
p.add_argument("--message", action="append", required=True,
|
|
76
78
|
help="User message to send. Repeat for multi-turn histories.")
|
|
79
|
+
p.add_argument("--timeout", type=float, default=120.0,
|
|
80
|
+
help="Per-message response timeout in seconds (default: 120).")
|
|
77
81
|
p.add_argument("--out", default=None,
|
|
78
82
|
help="Write complete create response/conversation artifact to this file; stdout stays compact.")
|
|
79
83
|
p.set_defaults(func=run_create)
|
|
80
84
|
|
|
85
|
+
# codeer history send <id> --message ...
|
|
86
|
+
p = sub.add_parser("send", help="Continue an existing persisted conversation history")
|
|
87
|
+
p.add_argument("history_id", type=int)
|
|
88
|
+
p.add_argument("--agent", default=None,
|
|
89
|
+
help="Agent ID override (defaults to the history's agent or CODEER_AGENT_ID)")
|
|
90
|
+
p.add_argument("--user", default=None,
|
|
91
|
+
help="external_user_id override (defaults to the history's user)")
|
|
92
|
+
p.add_argument("--message", action="append", required=True,
|
|
93
|
+
help="User message to send. Repeat to append multiple turns.")
|
|
94
|
+
p.add_argument("--timeout", type=float, default=120.0,
|
|
95
|
+
help="Per-message response timeout in seconds (default: 120).")
|
|
96
|
+
p.add_argument("--out", default=None,
|
|
97
|
+
help="Write complete send response/conversation artifact to this file; stdout stays compact.")
|
|
98
|
+
p.set_defaults(func=run_send)
|
|
99
|
+
|
|
81
100
|
|
|
82
101
|
def _parse_exclude(raw: str | None) -> list[str]:
|
|
83
102
|
if not raw:
|
|
@@ -253,11 +272,51 @@ def _history_url(client, workspace_id: str, history_id: int) -> str:
|
|
|
253
272
|
return f"{base}/workspaces/{workspace_id}/histories/{history_id}"
|
|
254
273
|
|
|
255
274
|
|
|
275
|
+
def _send_messages(
|
|
276
|
+
client,
|
|
277
|
+
*,
|
|
278
|
+
history_id: int,
|
|
279
|
+
agent_id: str,
|
|
280
|
+
external_user_id: str | None,
|
|
281
|
+
messages: list[str],
|
|
282
|
+
timeout: float,
|
|
283
|
+
) -> list[dict]:
|
|
284
|
+
results = []
|
|
285
|
+
for idx, message in enumerate(messages, 1):
|
|
286
|
+
log(f"sending turn {idx}/{len(messages)}")
|
|
287
|
+
try:
|
|
288
|
+
result = chats_mod.send_published_agent_message(
|
|
289
|
+
client,
|
|
290
|
+
chat_id=history_id,
|
|
291
|
+
message=message,
|
|
292
|
+
agent_id=agent_id,
|
|
293
|
+
external_user_id=external_user_id,
|
|
294
|
+
stream=False,
|
|
295
|
+
timeout=timeout,
|
|
296
|
+
)
|
|
297
|
+
except TransportError as exc:
|
|
298
|
+
body = dict(exc.body) if isinstance(exc.body, dict) else {}
|
|
299
|
+
body.update({"history_id": history_id, "turn": idx, "turn_count": len(messages)})
|
|
300
|
+
raise TransportError(
|
|
301
|
+
f"Failed while sending turn {idx}/{len(messages)} to history {history_id}: {exc.message}",
|
|
302
|
+
body,
|
|
303
|
+
) from exc
|
|
304
|
+
results.append(result)
|
|
305
|
+
return results
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _valid_timeout(timeout: float) -> bool:
|
|
309
|
+
return math.isfinite(timeout) and timeout > 0
|
|
310
|
+
|
|
311
|
+
|
|
256
312
|
def run_create(args, client) -> int:
|
|
257
313
|
agent_id = args.agent or os.environ.get("CODEER_AGENT_ID")
|
|
258
314
|
if not agent_id:
|
|
259
315
|
log("error: --agent is required or set CODEER_AGENT_ID")
|
|
260
316
|
return 2
|
|
317
|
+
if not _valid_timeout(args.timeout):
|
|
318
|
+
log("error: --timeout must be a finite number greater than zero")
|
|
319
|
+
return 2
|
|
261
320
|
|
|
262
321
|
workspace_id, _ = client.resolve_scope()
|
|
263
322
|
title = args.title or (args.message[0].strip()[:80] if args.message else "CLI conversation")
|
|
@@ -269,19 +328,14 @@ def run_create(args, client) -> int:
|
|
|
269
328
|
external_user_id=args.user,
|
|
270
329
|
)
|
|
271
330
|
history_id = chat["id"]
|
|
272
|
-
message_results =
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
agent_id=agent_id,
|
|
281
|
-
external_user_id=args.user,
|
|
282
|
-
stream=False,
|
|
283
|
-
)
|
|
284
|
-
message_results.append(result)
|
|
331
|
+
message_results = _send_messages(
|
|
332
|
+
client,
|
|
333
|
+
history_id=history_id,
|
|
334
|
+
agent_id=agent_id,
|
|
335
|
+
external_user_id=args.user,
|
|
336
|
+
messages=args.message,
|
|
337
|
+
timeout=args.timeout,
|
|
338
|
+
)
|
|
285
339
|
|
|
286
340
|
conversations = hist_mod.get_conversations(client, history_id)
|
|
287
341
|
out = {
|
|
@@ -303,3 +357,61 @@ def run_create(args, client) -> int:
|
|
|
303
357
|
"wrote_full_detail": bool(args.out),
|
|
304
358
|
})
|
|
305
359
|
return 0
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def run_send(args, client) -> int:
|
|
363
|
+
if not _valid_timeout(args.timeout):
|
|
364
|
+
log("error: --timeout must be a finite number greater than zero")
|
|
365
|
+
return 2
|
|
366
|
+
|
|
367
|
+
history = hist_mod.get(client, args.history_id)
|
|
368
|
+
history_agent = history.get("agent") or {}
|
|
369
|
+
history_meta = history.get("meta") or {}
|
|
370
|
+
agent_id = (
|
|
371
|
+
args.agent
|
|
372
|
+
or history.get("agent_id")
|
|
373
|
+
or history_agent.get("id")
|
|
374
|
+
or history_meta.get("conversation_agent_id")
|
|
375
|
+
or os.environ.get("CODEER_AGENT_ID")
|
|
376
|
+
)
|
|
377
|
+
if not agent_id:
|
|
378
|
+
log("error: could not resolve agent from history; pass --agent or set CODEER_AGENT_ID")
|
|
379
|
+
return 2
|
|
380
|
+
|
|
381
|
+
external_user_id = (
|
|
382
|
+
args.user
|
|
383
|
+
if args.user is not None
|
|
384
|
+
else (
|
|
385
|
+
history.get("external_user_id")
|
|
386
|
+
or history_meta.get("external_user_id")
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
workspace_id, _ = client.resolve_scope()
|
|
390
|
+
message_results = _send_messages(
|
|
391
|
+
client,
|
|
392
|
+
history_id=args.history_id,
|
|
393
|
+
agent_id=agent_id,
|
|
394
|
+
external_user_id=external_user_id,
|
|
395
|
+
messages=args.message,
|
|
396
|
+
timeout=args.timeout,
|
|
397
|
+
)
|
|
398
|
+
conversations = hist_mod.get_conversations(client, args.history_id)
|
|
399
|
+
out = {
|
|
400
|
+
"agent_id": agent_id,
|
|
401
|
+
"history_id": args.history_id,
|
|
402
|
+
"external_user_id": external_user_id,
|
|
403
|
+
"url": _history_url(client, workspace_id, args.history_id),
|
|
404
|
+
"messages": message_results,
|
|
405
|
+
"conversations": conversations,
|
|
406
|
+
}
|
|
407
|
+
write_json(args.out, strip_noisy_fields(out))
|
|
408
|
+
print_json({
|
|
409
|
+
"agent_id": agent_id,
|
|
410
|
+
"history_id": args.history_id,
|
|
411
|
+
"external_user_id": external_user_id,
|
|
412
|
+
"url": out["url"],
|
|
413
|
+
"message_count": len(message_results),
|
|
414
|
+
"turn_count": len(conversations),
|
|
415
|
+
"wrote_full_detail": bool(args.out),
|
|
416
|
+
})
|
|
417
|
+
return 0
|
|
@@ -141,21 +141,20 @@ def replace_case_evaluator_infos(
|
|
|
141
141
|
|
|
142
142
|
# --- case labels -----------------------------------------------------------
|
|
143
143
|
|
|
144
|
-
def list_case_labels(client: CodeerClient
|
|
145
|
-
return client.get(
|
|
144
|
+
def list_case_labels(client: CodeerClient) -> list[dict]:
|
|
145
|
+
return client.get("/external/eval/case-labels")
|
|
146
146
|
|
|
147
147
|
|
|
148
148
|
def create_case_label(
|
|
149
149
|
client: CodeerClient,
|
|
150
150
|
*,
|
|
151
|
-
workspace_id: str,
|
|
152
151
|
name: str,
|
|
153
152
|
color: Optional[str] = None,
|
|
154
153
|
) -> dict:
|
|
155
154
|
body: dict[str, Any] = {"name": name}
|
|
156
155
|
if color is not None:
|
|
157
156
|
body["color"] = color
|
|
158
|
-
return client.post(
|
|
157
|
+
return client.post("/external/eval/case-labels", json=body)
|
|
159
158
|
|
|
160
159
|
|
|
161
160
|
def update_case_label(
|
|
@@ -170,11 +169,11 @@ def update_case_label(
|
|
|
170
169
|
body["name"] = name
|
|
171
170
|
if color is not None:
|
|
172
171
|
body["color"] = color
|
|
173
|
-
return client.put(f"/eval/case-labels/{label_id}", json=body)
|
|
172
|
+
return client.put(f"/external/eval/case-labels/{label_id}", json=body)
|
|
174
173
|
|
|
175
174
|
|
|
176
175
|
def delete_case_label(client: CodeerClient, *, label_id: str) -> dict:
|
|
177
|
-
return client.delete(f"/eval/case-labels/{label_id}")
|
|
176
|
+
return client.delete(f"/external/eval/case-labels/{label_id}")
|
|
178
177
|
|
|
179
178
|
|
|
180
179
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from codeer_cli.client import CodeerClient, TransportError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClientTransportTests(unittest.TestCase):
|
|
11
|
+
def test_request_forwards_per_request_timeout(self) -> None:
|
|
12
|
+
requests: list[httpx.Request] = []
|
|
13
|
+
|
|
14
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
15
|
+
requests.append(request)
|
|
16
|
+
return httpx.Response(200, json={"ok": True})
|
|
17
|
+
|
|
18
|
+
client = self._client(handler)
|
|
19
|
+
try:
|
|
20
|
+
result = client.post("/chats/1/messages", json={"message": "Hi"}, timeout=120.0)
|
|
21
|
+
finally:
|
|
22
|
+
client.close()
|
|
23
|
+
|
|
24
|
+
self.assertEqual(result, {"ok": True})
|
|
25
|
+
self.assertEqual(requests[0].extensions["timeout"]["read"], 120.0)
|
|
26
|
+
|
|
27
|
+
def test_timeout_becomes_transport_error_with_uncertain_write_outcome(self) -> None:
|
|
28
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
29
|
+
raise httpx.ReadTimeout("response took too long", request=request)
|
|
30
|
+
|
|
31
|
+
client = self._client(handler)
|
|
32
|
+
try:
|
|
33
|
+
with self.assertRaises(TransportError) as raised:
|
|
34
|
+
client.post("/chats/1/messages", json={"message": "Hi"}, timeout=120.0)
|
|
35
|
+
finally:
|
|
36
|
+
client.close()
|
|
37
|
+
|
|
38
|
+
self.assertIn("timed out after 120s", raised.exception.message)
|
|
39
|
+
self.assertIn("inspect current state before retrying", raised.exception.message)
|
|
40
|
+
self.assertEqual(raised.exception.status, 0)
|
|
41
|
+
self.assertTrue(raised.exception.body["outcome_uncertain"])
|
|
42
|
+
|
|
43
|
+
def test_connection_error_becomes_transport_error(self) -> None:
|
|
44
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
45
|
+
raise httpx.ConnectError("connection refused", request=request)
|
|
46
|
+
|
|
47
|
+
client = self._client(handler)
|
|
48
|
+
try:
|
|
49
|
+
with self.assertRaises(TransportError) as raised:
|
|
50
|
+
client.get("/external/me")
|
|
51
|
+
finally:
|
|
52
|
+
client.close()
|
|
53
|
+
|
|
54
|
+
self.assertIn("Request failed: GET /external/me", raised.exception.message)
|
|
55
|
+
self.assertFalse(raised.exception.body["outcome_uncertain"])
|
|
56
|
+
|
|
57
|
+
def test_stream_connection_error_becomes_transport_error(self) -> None:
|
|
58
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
59
|
+
raise httpx.ConnectError("connection refused", request=request)
|
|
60
|
+
|
|
61
|
+
client = self._client(handler)
|
|
62
|
+
try:
|
|
63
|
+
with self.assertRaises(TransportError) as raised:
|
|
64
|
+
list(client.stream_sse("POST", "/chats/1/messages", json={"message": "Hi"}))
|
|
65
|
+
finally:
|
|
66
|
+
client.close()
|
|
67
|
+
|
|
68
|
+
self.assertIn("Request failed: POST /chats/1/messages", raised.exception.message)
|
|
69
|
+
self.assertFalse(raised.exception.body["outcome_uncertain"])
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _client(handler) -> CodeerClient:
|
|
73
|
+
client = CodeerClient(base_url="https://api.codeer.ai", api_key="test-key")
|
|
74
|
+
client._client.close()
|
|
75
|
+
client._client = httpx.Client(
|
|
76
|
+
base_url=client.base_url,
|
|
77
|
+
timeout=client.timeout,
|
|
78
|
+
transport=httpx.MockTransport(handler),
|
|
79
|
+
)
|
|
80
|
+
return client
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
unittest.main()
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stdout
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from codeer_cli import eval_ as eval_mod
|
|
12
|
+
from codeer_cli.commands.eval_cmd import (
|
|
13
|
+
_resolve_case_label_ids,
|
|
14
|
+
run_cases_apply,
|
|
15
|
+
run_label_create,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeClient:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.calls: list[tuple[str, str, dict]] = []
|
|
22
|
+
|
|
23
|
+
def get(self, path: str):
|
|
24
|
+
self.calls.append(("GET", path, {}))
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
def post(self, path: str, **kwargs):
|
|
28
|
+
self.calls.append(("POST", path, kwargs))
|
|
29
|
+
return {"id": "created"}
|
|
30
|
+
|
|
31
|
+
def put(self, path: str, **kwargs):
|
|
32
|
+
self.calls.append(("PUT", path, kwargs))
|
|
33
|
+
return {"id": "updated"}
|
|
34
|
+
|
|
35
|
+
def delete(self, path: str, **kwargs):
|
|
36
|
+
self.calls.append(("DELETE", path, kwargs))
|
|
37
|
+
return {"ok": True}
|
|
38
|
+
|
|
39
|
+
def resolve_scope(self):
|
|
40
|
+
return "ws-1", "org-1"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CasesApplyFakeClient(FakeClient):
|
|
44
|
+
def resolve_scope(self):
|
|
45
|
+
return "ws-1", None
|
|
46
|
+
|
|
47
|
+
def get(self, path: str):
|
|
48
|
+
self.calls.append(("GET", path, {}))
|
|
49
|
+
if path == "/external/eval/case-labels":
|
|
50
|
+
return []
|
|
51
|
+
if path == "/external/eval/agents/agent-1/cases":
|
|
52
|
+
return {
|
|
53
|
+
"cases": [
|
|
54
|
+
{"id": "case-1", "input": "First question", "note": "Keep me"},
|
|
55
|
+
{"id": "case-2", "input": "Second question", "meta": {"keep": True}},
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
def post(self, path: str, **kwargs):
|
|
61
|
+
self.calls.append(("POST", path, kwargs))
|
|
62
|
+
if path == "/external/eval/case-labels":
|
|
63
|
+
return {"id": "label-1", "name": kwargs["json"]["name"], "color": "#0969da"}
|
|
64
|
+
return {"id": "unexpected-case"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class EvalLabelClientTests(unittest.TestCase):
|
|
68
|
+
def test_create_case_sends_label_ids(self) -> None:
|
|
69
|
+
client = FakeClient()
|
|
70
|
+
|
|
71
|
+
eval_mod.create_case(
|
|
72
|
+
client, # type: ignore[arg-type]
|
|
73
|
+
agent_id="agent-1",
|
|
74
|
+
input="How much?",
|
|
75
|
+
label_ids=["10", "11"],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
self.assertEqual(client.calls[0][0], "POST")
|
|
79
|
+
self.assertEqual(client.calls[0][1], "/external/eval/cases")
|
|
80
|
+
self.assertEqual(client.calls[0][2]["json"]["label_ids"], ["10", "11"])
|
|
81
|
+
|
|
82
|
+
def test_update_case_sends_empty_label_ids_to_clear(self) -> None:
|
|
83
|
+
client = FakeClient()
|
|
84
|
+
|
|
85
|
+
eval_mod.update_case(
|
|
86
|
+
client, # type: ignore[arg-type]
|
|
87
|
+
"case-1",
|
|
88
|
+
label_ids=[],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.assertEqual(client.calls[0][0], "PUT")
|
|
92
|
+
self.assertEqual(client.calls[0][1], "/external/eval/cases/case-1")
|
|
93
|
+
self.assertEqual(client.calls[0][2]["json"]["label_ids"], [])
|
|
94
|
+
|
|
95
|
+
def test_case_label_crud_paths(self) -> None:
|
|
96
|
+
client = FakeClient()
|
|
97
|
+
|
|
98
|
+
eval_mod.list_case_labels(client) # type: ignore[arg-type]
|
|
99
|
+
eval_mod.create_case_label(client, name="Routing") # type: ignore[arg-type]
|
|
100
|
+
eval_mod.update_case_label(client, label_id="5", color="#0969da") # type: ignore[arg-type]
|
|
101
|
+
eval_mod.delete_case_label(client, label_id="5") # type: ignore[arg-type]
|
|
102
|
+
|
|
103
|
+
self.assertEqual(client.calls[0], ("GET", "/external/eval/case-labels", {}))
|
|
104
|
+
self.assertEqual(client.calls[1][0:2], ("POST", "/external/eval/case-labels"))
|
|
105
|
+
self.assertEqual(client.calls[1][2]["json"], {"name": "Routing"})
|
|
106
|
+
self.assertEqual(client.calls[2][0:2], ("PUT", "/external/eval/case-labels/5"))
|
|
107
|
+
self.assertEqual(client.calls[2][2]["json"], {"color": "#0969da"})
|
|
108
|
+
self.assertEqual(client.calls[3][0:2], ("DELETE", "/external/eval/case-labels/5"))
|
|
109
|
+
|
|
110
|
+
def test_label_create_dry_run_reports_active_api_key_workspace(self) -> None:
|
|
111
|
+
client = FakeClient()
|
|
112
|
+
args = SimpleNamespace(
|
|
113
|
+
name="Routing",
|
|
114
|
+
color="#0969da",
|
|
115
|
+
dry_run=True,
|
|
116
|
+
out=None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
stdout = io.StringIO()
|
|
120
|
+
with redirect_stdout(stdout):
|
|
121
|
+
result = run_label_create(args, client) # type: ignore[arg-type]
|
|
122
|
+
|
|
123
|
+
report = json.loads(stdout.getvalue())
|
|
124
|
+
self.assertEqual(result, 0)
|
|
125
|
+
self.assertEqual(report["workspace_id"], "ws-1")
|
|
126
|
+
self.assertEqual(report["path"], "/external/eval/case-labels")
|
|
127
|
+
self.assertEqual(client.calls, [])
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class EvalLabelManifestTests(unittest.TestCase):
|
|
131
|
+
def test_resolve_case_label_ids_from_ids_and_names(self) -> None:
|
|
132
|
+
label_ids, label_names = _resolve_case_label_ids(
|
|
133
|
+
{"label_ids": ["7"], "labels": ["Routing", "Billing"]},
|
|
134
|
+
{
|
|
135
|
+
"routing": {"id": "8", "name": "Routing"},
|
|
136
|
+
"billing": {"id": "9", "name": "Billing"},
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self.assertEqual(label_ids, ["7", "8", "9"])
|
|
141
|
+
self.assertEqual(label_names, ["Routing", "Billing"])
|
|
142
|
+
|
|
143
|
+
def test_resolve_case_label_ids_dedupes_preserving_order(self) -> None:
|
|
144
|
+
label_ids, _ = _resolve_case_label_ids(
|
|
145
|
+
{"label_ids": ["7", "8"], "labels": ["Routing"]},
|
|
146
|
+
{"routing": {"id": "8", "name": "Routing"}},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
self.assertEqual(label_ids, ["7", "8"])
|
|
150
|
+
|
|
151
|
+
def test_unknown_label_name_is_error(self) -> None:
|
|
152
|
+
with self.assertRaises(ValueError):
|
|
153
|
+
_resolve_case_label_ids({"labels": ["Missing"]}, {})
|
|
154
|
+
|
|
155
|
+
def test_cases_apply_create_labels_dry_run_reports_without_writing(self) -> None:
|
|
156
|
+
client = CasesApplyFakeClient()
|
|
157
|
+
args = self._cases_apply_args(dry_run=True)
|
|
158
|
+
|
|
159
|
+
with patch.object(Path, "read_text", return_value=json.dumps(self._manifest())):
|
|
160
|
+
stdout = io.StringIO()
|
|
161
|
+
with redirect_stdout(stdout):
|
|
162
|
+
result = run_cases_apply(args, client) # type: ignore[arg-type]
|
|
163
|
+
|
|
164
|
+
report = json.loads(stdout.getvalue())
|
|
165
|
+
self.assertEqual(result, 0)
|
|
166
|
+
self.assertEqual(report["would_create_case_labels"], ["Missing"])
|
|
167
|
+
self.assertEqual([update["case_id"] for update in report["updates"]], ["case-1", "case-2"])
|
|
168
|
+
self.assertTrue(all(update["label_ids"] == ["(new:Missing)"] for update in report["updates"]))
|
|
169
|
+
self.assertTrue(all(call[0] == "GET" for call in client.calls))
|
|
170
|
+
|
|
171
|
+
def test_cases_apply_creates_one_label_and_only_updates_existing_cases(self) -> None:
|
|
172
|
+
client = CasesApplyFakeClient()
|
|
173
|
+
args = self._cases_apply_args(dry_run=False)
|
|
174
|
+
|
|
175
|
+
with patch.object(Path, "read_text", return_value=json.dumps(self._manifest())):
|
|
176
|
+
stdout = io.StringIO()
|
|
177
|
+
with redirect_stdout(stdout):
|
|
178
|
+
result = run_cases_apply(args, client) # type: ignore[arg-type]
|
|
179
|
+
|
|
180
|
+
report = json.loads(stdout.getvalue())
|
|
181
|
+
label_creates = [
|
|
182
|
+
call for call in client.calls
|
|
183
|
+
if call[0:2] == ("POST", "/external/eval/case-labels")
|
|
184
|
+
]
|
|
185
|
+
case_creates = [
|
|
186
|
+
call for call in client.calls
|
|
187
|
+
if call[0:2] == ("POST", "/external/eval/cases")
|
|
188
|
+
]
|
|
189
|
+
case_updates = [
|
|
190
|
+
call for call in client.calls
|
|
191
|
+
if call[0] == "PUT" and call[1] in {
|
|
192
|
+
"/external/eval/cases/case-1",
|
|
193
|
+
"/external/eval/cases/case-2",
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
self.assertEqual(result, 0)
|
|
198
|
+
self.assertEqual(len(label_creates), 1)
|
|
199
|
+
self.assertEqual(case_creates, [])
|
|
200
|
+
self.assertEqual(len(case_updates), 2)
|
|
201
|
+
self.assertTrue(all(call[2]["json"] == {"label_ids": ["label-1"]} for call in case_updates))
|
|
202
|
+
self.assertEqual([row["case_id"] for row in report["reused"]], ["case-1", "case-2"])
|
|
203
|
+
self.assertEqual(report["created_case_labels"][0]["name"], "Missing")
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _cases_apply_args(*, dry_run: bool) -> SimpleNamespace:
|
|
207
|
+
return SimpleNamespace(
|
|
208
|
+
cases="manifest.json",
|
|
209
|
+
agent="agent-1",
|
|
210
|
+
attachments_dir=None,
|
|
211
|
+
allow_duplicates=False,
|
|
212
|
+
create_labels=True,
|
|
213
|
+
dry_run=dry_run,
|
|
214
|
+
out=None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def _manifest() -> dict:
|
|
219
|
+
return {
|
|
220
|
+
"cases": [
|
|
221
|
+
{
|
|
222
|
+
"label": "First",
|
|
223
|
+
"input": "First question",
|
|
224
|
+
"labels": ["Missing"],
|
|
225
|
+
"rubrics": {"eval-1": "Must pass"},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
"label": "Second",
|
|
229
|
+
"input": "Second question",
|
|
230
|
+
"labels": ["Missing"],
|
|
231
|
+
"rubrics": {"eval-1": "Must pass"},
|
|
232
|
+
},
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
unittest.main()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from contextlib import redirect_stdout
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from types import SimpleNamespace
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from codeer_cli import chats as chats_mod
|
|
10
|
+
from codeer_cli.client import TransportError
|
|
11
|
+
from codeer_cli.commands import history as history_cmd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeClient:
|
|
15
|
+
base_url = "https://api.codeer.ai"
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.calls: list[tuple[str, dict]] = []
|
|
19
|
+
|
|
20
|
+
def resolve_scope(self) -> tuple[str, str]:
|
|
21
|
+
return "workspace-1", "organization-1"
|
|
22
|
+
|
|
23
|
+
def post(self, path: str, **kwargs):
|
|
24
|
+
self.calls.append((path, kwargs))
|
|
25
|
+
return {"ok": True}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HistorySendTests(unittest.TestCase):
|
|
29
|
+
def test_chat_send_forwards_response_timeout(self) -> None:
|
|
30
|
+
client = FakeClient()
|
|
31
|
+
|
|
32
|
+
chats_mod.send_published_agent_message(
|
|
33
|
+
client, # type: ignore[arg-type]
|
|
34
|
+
chat_id=18649,
|
|
35
|
+
message="Continue",
|
|
36
|
+
agent_id="agent-1",
|
|
37
|
+
timeout=120.0,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
self.assertEqual(client.calls[0][0], "/chats/18649/messages")
|
|
41
|
+
self.assertEqual(client.calls[0][1]["timeout"], 120.0)
|
|
42
|
+
|
|
43
|
+
def test_run_send_resolves_agent_and_appends_to_existing_history(self) -> None:
|
|
44
|
+
client = FakeClient()
|
|
45
|
+
args = SimpleNamespace(
|
|
46
|
+
history_id=18649,
|
|
47
|
+
agent=None,
|
|
48
|
+
user=None,
|
|
49
|
+
message=["Use the recommended options"],
|
|
50
|
+
timeout=120.0,
|
|
51
|
+
out=None,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
with (
|
|
55
|
+
patch.object(
|
|
56
|
+
history_cmd.hist_mod,
|
|
57
|
+
"get",
|
|
58
|
+
return_value={
|
|
59
|
+
"id": 18649,
|
|
60
|
+
"agent_id": None,
|
|
61
|
+
"external_user_id": None,
|
|
62
|
+
"meta": {
|
|
63
|
+
"conversation_agent_id": "agent-1",
|
|
64
|
+
"external_user_id": "user-1",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
),
|
|
68
|
+
patch.object(
|
|
69
|
+
history_cmd.hist_mod,
|
|
70
|
+
"get_conversations",
|
|
71
|
+
return_value=[
|
|
72
|
+
{"role": "user", "content": "Initial"},
|
|
73
|
+
{"role": "assistant", "content": "Reply"},
|
|
74
|
+
{"role": "user", "content": "Use the recommended options"},
|
|
75
|
+
{"role": "assistant", "content": "Final"},
|
|
76
|
+
],
|
|
77
|
+
),
|
|
78
|
+
patch.object(
|
|
79
|
+
history_cmd.chats_mod,
|
|
80
|
+
"send_published_agent_message",
|
|
81
|
+
return_value={"conversation_id": 4},
|
|
82
|
+
) as send,
|
|
83
|
+
redirect_stdout(StringIO()),
|
|
84
|
+
):
|
|
85
|
+
result = history_cmd.run_send(args, client)
|
|
86
|
+
|
|
87
|
+
self.assertEqual(result, 0)
|
|
88
|
+
send.assert_called_once_with(
|
|
89
|
+
client,
|
|
90
|
+
chat_id=18649,
|
|
91
|
+
message="Use the recommended options",
|
|
92
|
+
agent_id="agent-1",
|
|
93
|
+
external_user_id="user-1",
|
|
94
|
+
stream=False,
|
|
95
|
+
timeout=120.0,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def test_run_send_rejects_non_positive_timeout(self) -> None:
|
|
99
|
+
client = FakeClient()
|
|
100
|
+
args = SimpleNamespace(
|
|
101
|
+
history_id=18649,
|
|
102
|
+
agent=None,
|
|
103
|
+
user=None,
|
|
104
|
+
message=["Continue"],
|
|
105
|
+
timeout=0,
|
|
106
|
+
out=None,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
with redirect_stdout(StringIO()):
|
|
110
|
+
result = history_cmd.run_send(args, client)
|
|
111
|
+
|
|
112
|
+
self.assertEqual(result, 2)
|
|
113
|
+
|
|
114
|
+
def test_run_create_rejects_non_finite_timeout(self) -> None:
|
|
115
|
+
client = FakeClient()
|
|
116
|
+
args = SimpleNamespace(
|
|
117
|
+
agent="agent-1",
|
|
118
|
+
title=None,
|
|
119
|
+
user=None,
|
|
120
|
+
message=["Start"],
|
|
121
|
+
timeout=float("nan"),
|
|
122
|
+
out=None,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
with redirect_stdout(StringIO()):
|
|
126
|
+
result = history_cmd.run_create(args, client)
|
|
127
|
+
|
|
128
|
+
self.assertEqual(result, 2)
|
|
129
|
+
self.assertEqual(client.calls, [])
|
|
130
|
+
|
|
131
|
+
def test_send_transport_error_includes_history_and_turn_context(self) -> None:
|
|
132
|
+
client = FakeClient()
|
|
133
|
+
|
|
134
|
+
with patch.object(
|
|
135
|
+
history_cmd.chats_mod,
|
|
136
|
+
"send_published_agent_message",
|
|
137
|
+
side_effect=TransportError(
|
|
138
|
+
"Request timed out",
|
|
139
|
+
{"outcome_uncertain": True},
|
|
140
|
+
),
|
|
141
|
+
):
|
|
142
|
+
with self.assertRaises(TransportError) as raised:
|
|
143
|
+
history_cmd._send_messages(
|
|
144
|
+
client,
|
|
145
|
+
history_id=18649,
|
|
146
|
+
agent_id="agent-1",
|
|
147
|
+
external_user_id="user-1",
|
|
148
|
+
messages=["First", "Second"],
|
|
149
|
+
timeout=120.0,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
self.assertIn("turn 1/2", raised.exception.message)
|
|
153
|
+
self.assertEqual(raised.exception.body["history_id"], 18649)
|
|
154
|
+
self.assertEqual(raised.exception.body["turn"], 1)
|
|
155
|
+
self.assertTrue(raised.exception.body["outcome_uncertain"])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
unittest.main()
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import unittest
|
|
4
|
-
|
|
5
|
-
from codeer_cli import eval_ as eval_mod
|
|
6
|
-
from codeer_cli.commands.eval_cmd import _resolve_case_label_ids
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class FakeClient:
|
|
10
|
-
def __init__(self) -> None:
|
|
11
|
-
self.calls: list[tuple[str, str, dict]] = []
|
|
12
|
-
|
|
13
|
-
def get(self, path: str):
|
|
14
|
-
self.calls.append(("GET", path, {}))
|
|
15
|
-
return []
|
|
16
|
-
|
|
17
|
-
def post(self, path: str, **kwargs):
|
|
18
|
-
self.calls.append(("POST", path, kwargs))
|
|
19
|
-
return {"id": "created"}
|
|
20
|
-
|
|
21
|
-
def put(self, path: str, **kwargs):
|
|
22
|
-
self.calls.append(("PUT", path, kwargs))
|
|
23
|
-
return {"id": "updated"}
|
|
24
|
-
|
|
25
|
-
def delete(self, path: str, **kwargs):
|
|
26
|
-
self.calls.append(("DELETE", path, kwargs))
|
|
27
|
-
return {"ok": True}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class EvalLabelClientTests(unittest.TestCase):
|
|
31
|
-
def test_create_case_sends_label_ids(self) -> None:
|
|
32
|
-
client = FakeClient()
|
|
33
|
-
|
|
34
|
-
eval_mod.create_case(
|
|
35
|
-
client, # type: ignore[arg-type]
|
|
36
|
-
agent_id="agent-1",
|
|
37
|
-
input="How much?",
|
|
38
|
-
label_ids=["10", "11"],
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
self.assertEqual(client.calls[0][0], "POST")
|
|
42
|
-
self.assertEqual(client.calls[0][1], "/external/eval/cases")
|
|
43
|
-
self.assertEqual(client.calls[0][2]["json"]["label_ids"], ["10", "11"])
|
|
44
|
-
|
|
45
|
-
def test_update_case_sends_empty_label_ids_to_clear(self) -> None:
|
|
46
|
-
client = FakeClient()
|
|
47
|
-
|
|
48
|
-
eval_mod.update_case(
|
|
49
|
-
client, # type: ignore[arg-type]
|
|
50
|
-
"case-1",
|
|
51
|
-
label_ids=[],
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
self.assertEqual(client.calls[0][0], "PUT")
|
|
55
|
-
self.assertEqual(client.calls[0][1], "/external/eval/cases/case-1")
|
|
56
|
-
self.assertEqual(client.calls[0][2]["json"]["label_ids"], [])
|
|
57
|
-
|
|
58
|
-
def test_case_label_crud_paths(self) -> None:
|
|
59
|
-
client = FakeClient()
|
|
60
|
-
|
|
61
|
-
eval_mod.list_case_labels(client, workspace_id="ws-1") # type: ignore[arg-type]
|
|
62
|
-
eval_mod.create_case_label(client, workspace_id="ws-1", name="Routing") # type: ignore[arg-type]
|
|
63
|
-
eval_mod.update_case_label(client, label_id="5", color="#0969da") # type: ignore[arg-type]
|
|
64
|
-
eval_mod.delete_case_label(client, label_id="5") # type: ignore[arg-type]
|
|
65
|
-
|
|
66
|
-
self.assertEqual(client.calls[0], ("GET", "/eval/workspaces/ws-1/case-labels", {}))
|
|
67
|
-
self.assertEqual(client.calls[1][0:2], ("POST", "/eval/workspaces/ws-1/case-labels"))
|
|
68
|
-
self.assertEqual(client.calls[1][2]["json"], {"name": "Routing"})
|
|
69
|
-
self.assertEqual(client.calls[2][0:2], ("PUT", "/eval/case-labels/5"))
|
|
70
|
-
self.assertEqual(client.calls[2][2]["json"], {"color": "#0969da"})
|
|
71
|
-
self.assertEqual(client.calls[3][0:2], ("DELETE", "/eval/case-labels/5"))
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
class EvalLabelManifestTests(unittest.TestCase):
|
|
75
|
-
def test_resolve_case_label_ids_from_ids_and_names(self) -> None:
|
|
76
|
-
label_ids, label_names = _resolve_case_label_ids(
|
|
77
|
-
{"label_ids": ["7"], "labels": ["Routing", "Billing"]},
|
|
78
|
-
{
|
|
79
|
-
"routing": {"id": "8", "name": "Routing"},
|
|
80
|
-
"billing": {"id": "9", "name": "Billing"},
|
|
81
|
-
},
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
self.assertEqual(label_ids, ["7", "8", "9"])
|
|
85
|
-
self.assertEqual(label_names, ["Routing", "Billing"])
|
|
86
|
-
|
|
87
|
-
def test_resolve_case_label_ids_dedupes_preserving_order(self) -> None:
|
|
88
|
-
label_ids, _ = _resolve_case_label_ids(
|
|
89
|
-
{"label_ids": ["7", "8"], "labels": ["Routing"]},
|
|
90
|
-
{"routing": {"id": "8", "name": "Routing"}},
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
self.assertEqual(label_ids, ["7", "8"])
|
|
94
|
-
|
|
95
|
-
def test_unknown_label_name_is_error(self) -> None:
|
|
96
|
-
with self.assertRaises(ValueError):
|
|
97
|
-
_resolve_case_label_ids({"labels": ["Missing"]}, {})
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if __name__ == "__main__":
|
|
101
|
-
unittest.main()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|