codeer-cli 0.1.10__tar.gz → 0.1.11__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 (38) hide show
  1. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/API_REFERENCE.md +34 -25
  2. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/PKG-INFO +8 -6
  3. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/README.md +7 -5
  4. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/pyproject.toml +1 -1
  5. codeer_cli-0.1.11/src/codeer_cli/chats.py +201 -0
  6. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/client.py +30 -5
  7. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/history.py +26 -12
  8. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/parse.py +7 -8
  9. codeer_cli-0.1.11/tests/test_chats_v2.py +135 -0
  10. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_client_transport.py +48 -0
  11. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_history_send.py +52 -12
  12. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/uv.lock +1 -1
  13. codeer_cli-0.1.10/src/codeer_cli/chats.py +0 -87
  14. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/.gitignore +0 -0
  15. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/__init__.py +0 -0
  16. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/_validate.py +0 -0
  17. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/agents.py +0 -0
  18. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/cli.py +0 -0
  19. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/__init__.py +0 -0
  20. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/_util.py +0 -0
  21. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/agent.py +0 -0
  22. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/check.py +0 -0
  23. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/eval_cmd.py +0 -0
  24. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/kb.py +0 -0
  25. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/model.py +0 -0
  26. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/profile.py +0 -0
  27. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/constants.py +0 -0
  28. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/eval_.py +0 -0
  29. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/histories.py +0 -0
  30. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/kb.py +0 -0
  31. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/models.py +0 -0
  32. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_agent_handoff.py +0 -0
  33. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_eval_labels.py +0 -0
  34. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_eval_pairs.py +0 -0
  35. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_kb_nodes.py +0 -0
  36. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_kb_ranges.py +0 -0
  37. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_models.py +0 -0
  38. {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_util.py +0 -0
@@ -3,8 +3,9 @@
3
3
  > This file is the request-shape reference for maintaining `codeer-cli`.
4
4
 
5
5
  The 9 stages below mirror the user-docs lifecycle (`agent-creation` →
6
- `optimization-loop` → `publish`). Every path is under `/api/v1/`. All endpoints
7
- authenticate via `x-api-key` from `CODEER_API_KEY`.
6
+ `optimization-loop` → `publish`). Agent, KB, eval, history, and model paths are
7
+ under `/api/v1/`; Chat create/message/read paths use `/api/v2/chats`. All
8
+ endpoints authenticate via `x-api-key` from `CODEER_API_KEY`.
8
9
 
9
10
  Envelope: successful responses look like
10
11
  `{"error_code": 0, "message": "", "pagination": null, "data": <payload>}`.
@@ -140,19 +141,21 @@ codeer kb files --kb-id <kb-id>
140
141
  codeer kb faq-create --context-object-id <snapshot-object-id> --question "..." --range 12:0-12:42 --dry-run
141
142
  ```
142
143
 
143
- ## Stage 3 — Live Test on a specific version
144
+ ## Stage 3 — Live Test on a specific version (legacy V1 compatibility)
144
145
 
145
146
  | Method & path | Purpose |
146
147
  | --- | --- |
147
- | `POST /chats` | Create a new chat session bound to an agent |
148
- | `POST /chats/{chat_id}/messages` | Send a message; **SSE stream** of tool calls + reasoning + final text |
149
- | `GET /chats/{chat_id}/messages` | Read historical messages for a chat |
150
- | `POST /chats/{chat_id}/regenerate` | Re-run the last turn |
151
- | `POST /chats/{chat_id}/messages/{msg_id}/feedbacks` | Thumbs up/down on a reply |
152
-
153
- `POST /chats/.../messages` requires `agent_history_id` — this is the key hook
154
- for the apply → test → publish workflow. Pass the draft `AgentHistory.id` from
155
- `PUT /agents/{id}` to test an unpublished version.
148
+ | `POST /api/v1/chats` | Create a legacy chat session bound to an agent |
149
+ | `POST /api/v1/chats/{chat_id}/messages` | Send a version-pinned message through legacy SSE |
150
+ | `GET /api/v1/chats/{chat_id}/messages` | Read legacy messages for a chat |
151
+ | `POST /api/v1/chats/{chat_id}/regenerate` | Re-run the last turn |
152
+ | `POST /api/v1/chats/{chat_id}/messages/{msg_id}/feedbacks` | Thumbs up/down on a reply |
153
+
154
+ Legacy V1 `POST /chats/.../messages` accepts `agent_history_id` — this is the
155
+ compatibility hook for the apply → test → publish workflow. Pass the draft
156
+ `AgentHistory.id` from `PUT /agents/{id}` to test an unpublished version.
157
+ The API-key external flow in Chat V2 accepts `agent_id` and runs its published
158
+ version; it does not currently replace this draft-pinning path.
156
159
 
157
160
  ## Stage 4 — Version management
158
161
 
@@ -267,8 +270,9 @@ the public CLI.
267
270
 
268
271
  | Method & path | Purpose |
269
272
  | --- | --- |
270
- | `POST /chats` | Create a persisted history using an agent's current published version |
271
- | `POST /chats/{id}/messages` | Append a turn to an existing persisted history using the current published version |
273
+ | `POST /api/v2/chats` | Create a persisted history using an agent's current published version |
274
+ | `POST /api/v2/chats/{id}/messages` | Append a turn through structured SSE using the current published version |
275
+ | `GET /api/v2/chats/{id}/messages` | Read persisted Chat V2 conversation parts |
272
276
  | `GET /histories?agent_id=X&feedback_filter=improve_feedback&external_user_id=…` | List conversations with filters |
273
277
  | `GET /histories/{id}` | Read one history's metadata |
274
278
  | `GET /histories/{id}/conversations` | Full conversation turns incl. tool calls |
@@ -276,9 +280,11 @@ the public CLI.
276
280
  | `POST /histories/{hid}/conversations/{cid}/score` | Numeric score |
277
281
 
278
282
  The CLI exposes the first two operations as `codeer history create` and
279
- `codeer history send`. Non-streaming message requests default to a 120-second
280
- per-message timeout. A timeout has an uncertain write outcome, so read the
281
- history before retrying to avoid duplicate turns.
283
+ `codeer history send`. Messages explicitly set `stream: true`, consume Chat V2
284
+ SSE, and require `response.completed` before reporting success. Their
285
+ per-message SSE read timeout defaults to 240 seconds. A timeout,
286
+ `response.failed`, or disconnect before completion has an uncertain write
287
+ outcome, so read the history before retrying to avoid duplicate turns.
282
288
 
283
289
  `feedback_filter` accepts the `FeedbackFilterType` enum values:
284
290
  `no_feedback`, `with_feedback`, `helpful_feedback`, `improve_feedback`.
@@ -357,7 +363,7 @@ said (scope, factuality, tool-use rules).
357
363
 
358
364
  ### 4. Agent version pinning works everywhere — use it
359
365
 
360
- Both `POST /chats/{id}/messages` (`agent_history_id` required) and
366
+ Legacy V1 `POST /chats/{id}/messages` (`agent_history_id` required) and
361
367
  `POST /eval/trigger` (`agent_history_id` optional, null = live state) accept
362
368
  the draft history id. The apply-→-test-→-publish loop:
363
369
 
@@ -463,9 +469,10 @@ pass. Workspace scope is inferred from the API-key virtual user profile.
463
469
 
464
470
  ### 11. Tool args + outputs are NOT persisted in history reads
465
471
 
466
- Conversations have only three roles (`OpenAIChatRole = system | user | assistant`)
467
- — there is no `tool` role row. When you read a history, here's what you can
468
- and can't recover from each assistant turn:
472
+ Legacy V1 `History` conversation rows have only three roles
473
+ (`OpenAIChatRole = system | user | assistant`) — there is no `tool` role row.
474
+ When you read those rows through `/histories/{id}/conversations`, here's what
475
+ you can and can't recover from each assistant turn:
469
476
 
470
477
  | Recoverable | Where |
471
478
  | --- | --- |
@@ -481,10 +488,12 @@ and can't recover from each assistant turn:
481
488
  | Tool **outputs** (raw JSON returned by the tool) | same — stored only as derived `primary_sources` for retrieval tools |
482
489
  | Reasoning steps mid-turn | `meta.reasoning_steps` is currently always `null` |
483
490
 
484
- If you need full tool I/O, capture it at execution time via the chat SSE
485
- stream (`POST /chats/{id}/messages`), not from history reads. For after-the-
486
- fact analysis, the persisted shape is sufficient to surface tool-selection
487
- patterns, token costs, and which sources the agent ended up citing.
491
+ Chat V2 improves this contract: structured SSE emits tool calls and returns as
492
+ `response.part.created` / `response.part.completed`, and
493
+ `GET /api/v2/chats/{id}/messages` reads the persisted conversation parts.
494
+ Capture the SSE artifact with `--out` when exact event order matters; use the
495
+ V2 message read for persisted after-the-fact tool I/O. The legacy v1 history
496
+ read remains useful for compact turn-level analysis.
488
497
 
489
498
  ### 10. A KB has exactly ONE level of folders — no nesting
490
499
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeer-cli
3
- Version: 0.1.10
3
+ Version: 0.1.11
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
@@ -172,15 +172,17 @@ Use this pattern during agent lifecycle work:
172
172
  ```bash
173
173
  codeer agent list
174
174
  codeer history list --agent <agent-id> --limit 50
175
- codeer history create --agent <agent-id> --message "Review this plan" --timeout 120
176
- codeer history send <history-id> --message "Use the recommended options" --timeout 120
175
+ codeer history create --agent <agent-id> --message "Review this plan" --timeout 240
176
+ codeer history send <history-id> --message "Use the recommended options" --timeout 240
177
177
  codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
178
178
  ```
179
179
 
180
180
  `history create` and `history send` use the agent's current published version.
181
- Their per-message timeout defaults to 120 seconds. If a write request times
182
- out, inspect the history before retrying: the server may have completed the
183
- turn after the client stopped waiting.
181
+ They use Chat V2 structured SSE with `stream: true`; their per-message read
182
+ timeout defaults to 240 seconds. Success requires a `response.completed`
183
+ event. If the stream times out, reports `response.failed`, or disconnects
184
+ early, inspect the history before retrying: the server may already have
185
+ persisted the turn.
184
186
 
185
187
  Eval case label commands always operate on the active API-key workspace. They
186
188
  do not accept a workspace override; switch CLI profiles to target another
@@ -154,15 +154,17 @@ Use this pattern during agent lifecycle work:
154
154
  ```bash
155
155
  codeer agent list
156
156
  codeer history list --agent <agent-id> --limit 50
157
- codeer history create --agent <agent-id> --message "Review this plan" --timeout 120
158
- codeer history send <history-id> --message "Use the recommended options" --timeout 120
157
+ codeer history create --agent <agent-id> --message "Review this plan" --timeout 240
158
+ codeer history send <history-id> --message "Use the recommended options" --timeout 240
159
159
  codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
160
160
  ```
161
161
 
162
162
  `history create` and `history send` use the agent's current published version.
163
- Their per-message timeout defaults to 120 seconds. If a write request times
164
- out, inspect the history before retrying: the server may have completed the
165
- turn after the client stopped waiting.
163
+ They use Chat V2 structured SSE with `stream: true`; their per-message read
164
+ timeout defaults to 240 seconds. Success requires a `response.completed`
165
+ event. If the stream times out, reports `response.failed`, or disconnects
166
+ early, inspect the history before retrying: the server may already have
167
+ persisted the turn.
166
168
 
167
169
  Eval case label commands always operate on the active API-key workspace. They
168
170
  do not accept a workspace override; switch CLI profiles to target another
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codeer-cli"
7
- version = "0.1.10"
7
+ version = "0.1.11"
8
8
  description = "Command line tools for managing Codeer agents over the Codeer API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,201 @@
1
+ """Chat V2 creation, structured SSE responses, and persisted message reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterator, List, Optional
6
+
7
+ from .client import CodeerClient, TransportError
8
+
9
+
10
+ def create(
11
+ client: CodeerClient,
12
+ *,
13
+ agent_id: str,
14
+ title: Optional[str] = None,
15
+ external_user_id: Optional[str] = None,
16
+ ) -> dict:
17
+ body: dict[str, Any] = {
18
+ "agent_id": agent_id,
19
+ "name": title or "CLI conversation",
20
+ }
21
+ if external_user_id is not None:
22
+ body["external_user_id"] = external_user_id
23
+ return client.post("/chats", api_version="v2", json=body)
24
+
25
+
26
+ def send_published_agent_message(
27
+ client: CodeerClient,
28
+ *,
29
+ chat_id: int,
30
+ message: str,
31
+ agent_id: str,
32
+ external_user_id: Optional[str] = None,
33
+ attachment_ids: Optional[List[str]] = None,
34
+ stream: bool = True,
35
+ timeout: Optional[float] = None,
36
+ ) -> Iterator[dict] | dict:
37
+ """Send a user message through the API-key external chat flow.
38
+
39
+ API-key chat endpoints use the agent's published version. They accept
40
+ ``agent_id`` rather than ``agent_history_id``.
41
+ """
42
+ body: dict[str, Any] = {"message": message, "agent_id": agent_id, "stream": stream}
43
+ if external_user_id is not None:
44
+ body["external_user_id"] = external_user_id
45
+ if attachment_ids:
46
+ body["attached_file_uuids"] = attachment_ids
47
+
48
+ path = f"/chats/{chat_id}/messages"
49
+ if stream:
50
+ return client.stream_sse(
51
+ "POST",
52
+ path,
53
+ api_version="v2",
54
+ json=body,
55
+ timeout=timeout,
56
+ )
57
+ return client.post(path, api_version="v2", json=body, timeout=timeout)
58
+
59
+
60
+ def send_message(
61
+ client: CodeerClient,
62
+ *,
63
+ chat_id: int,
64
+ message: str,
65
+ agent_history_id: str,
66
+ attachment_ids: Optional[List[str]] = None,
67
+ stream: bool = True,
68
+ timeout: Optional[float] = None,
69
+ ) -> Iterator[dict] | dict:
70
+ """Use legacy Chat V1 to pin an unpublished agent version.
71
+
72
+ Chat V2's API-key external flow only accepts the published ``agent_id``.
73
+ Keep this low-level compatibility helper on V1 until V2 supports external
74
+ ``agent_history_id`` pinning. It remains streaming by default.
75
+ """
76
+ body: dict[str, Any] = {
77
+ "message": message,
78
+ "agent_history_id": agent_history_id,
79
+ "stream": stream,
80
+ }
81
+ if attachment_ids:
82
+ body["attachment_ids"] = attachment_ids
83
+
84
+ path = f"/chats/{chat_id}/messages"
85
+ if stream:
86
+ return client.stream_sse(
87
+ "POST",
88
+ path,
89
+ json=body,
90
+ timeout=timeout,
91
+ )
92
+ return client.post(path, json=body, timeout=timeout)
93
+
94
+
95
+ def collect_stream(events: Iterator[dict]) -> dict:
96
+ """Collect a Chat V2 SSE stream and require an explicit completion event."""
97
+ raw_events: list[dict] = []
98
+ parts: list[dict] = []
99
+ interactions: list[dict] = []
100
+ text_deltas: list[str] = []
101
+ final_text: str | None = None
102
+ response_id: str | None = None
103
+ conversation_group_id: str | None = None
104
+ updated_title: str | None = None
105
+ completed = False
106
+
107
+ for event in events:
108
+ data = event.get("data")
109
+ if data == "[DONE]":
110
+ continue
111
+ raw_events.append(event)
112
+ if not isinstance(data, dict):
113
+ continue
114
+
115
+ event_type = str(data.get("type") or event.get("event") or "message")
116
+ response_id = str(data.get("response_id") or response_id or "") or None
117
+ conversation_group_id = str(
118
+ data.get("conversation_group_id") or conversation_group_id or ""
119
+ ) or None
120
+
121
+ if event_type == "response.part.delta":
122
+ delta = data.get("delta")
123
+ if data.get("part_kind") == "text" and isinstance(delta, str):
124
+ text_deltas.append(delta)
125
+ elif event_type in ("response.part.created", "response.part.completed"):
126
+ part = data.get("part")
127
+ if isinstance(part, dict):
128
+ parts.append({"event": event_type, "part": part})
129
+ content = part.get("content")
130
+ if (
131
+ event_type == "response.part.completed"
132
+ and part.get("part_kind") == "text"
133
+ and isinstance(content, dict)
134
+ and isinstance(content.get("content"), str)
135
+ ):
136
+ final_text = content["content"]
137
+ elif event_type in ("response.interaction.created", "response.interaction.resolved"):
138
+ interactions.append(data)
139
+ elif event_type == "response.chat.title.updated":
140
+ name = data.get("name")
141
+ if isinstance(name, str):
142
+ updated_title = name
143
+ elif event_type == "response.failed":
144
+ message = data.get("message") or "Chat V2 stream failed"
145
+ raise TransportError(
146
+ str(message),
147
+ {
148
+ "code": data.get("code"),
149
+ "response_id": response_id,
150
+ "conversation_group_id": conversation_group_id,
151
+ "outcome_uncertain": True,
152
+ "events": raw_events,
153
+ },
154
+ )
155
+ elif event_type == "response.completed":
156
+ completed = True
157
+
158
+ if not completed:
159
+ raise TransportError(
160
+ "Chat V2 stream ended before response.completed. Inspect the history before retrying.",
161
+ {
162
+ "response_id": response_id,
163
+ "conversation_group_id": conversation_group_id,
164
+ "outcome_uncertain": True,
165
+ "events": raw_events,
166
+ },
167
+ )
168
+
169
+ return {
170
+ "stream": True,
171
+ "completed": True,
172
+ "response_id": response_id,
173
+ "conversation_group_id": conversation_group_id,
174
+ "final_text": final_text if final_text is not None else "".join(text_deltas),
175
+ "updated_title": updated_title,
176
+ "parts": parts,
177
+ "interactions": interactions,
178
+ "events": raw_events,
179
+ }
180
+
181
+
182
+ def list_messages(
183
+ client: CodeerClient,
184
+ chat_id: int,
185
+ *,
186
+ external_user_id: Optional[str] = None,
187
+ limit: int = 500,
188
+ ) -> dict:
189
+ params: dict[str, Any] = {"limit": limit, "offset": 0}
190
+ if external_user_id is not None:
191
+ params["external_user_id"] = external_user_id
192
+ return client.get(
193
+ f"/chats/{chat_id}/messages",
194
+ api_version="v2",
195
+ params=params,
196
+ )
197
+
198
+
199
+ def list_chats(client: CodeerClient) -> list[dict]:
200
+ """List chats through the legacy v1 endpoint; Chat V2 has no list route."""
201
+ return client.get("/chats")
@@ -12,11 +12,12 @@ from __future__ import annotations
12
12
  import json as json_lib
13
13
  import os
14
14
  from dataclasses import dataclass
15
- from typing import Any, Iterable, Iterator, Mapping, Optional
15
+ from typing import Any, Iterable, Iterator, Literal, Mapping, Optional
16
16
 
17
17
  import httpx
18
18
 
19
19
  DEFAULT_CODEER_API_BASE = "https://api.codeer.ai"
20
+ ApiVersion = Literal["v1", "v2"]
20
21
 
21
22
 
22
23
  class CodeerError(RuntimeError):
@@ -154,13 +155,14 @@ class CodeerClient:
154
155
  method: str,
155
156
  path: str,
156
157
  *,
158
+ api_version: ApiVersion = "v1",
157
159
  params: Optional[Mapping[str, Any]] = None,
158
160
  json: Any = None,
159
161
  files: Any = None,
160
162
  data: Any = None,
161
163
  timeout: Optional[float] = None,
162
164
  ) -> Any:
163
- url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
165
+ url = _api_url(path, api_version=api_version)
164
166
  request_kwargs: dict[str, Any] = {}
165
167
  if timeout is not None:
166
168
  request_kwargs["timeout"] = timeout
@@ -206,17 +208,28 @@ class CodeerClient:
206
208
  method: str,
207
209
  path: str,
208
210
  *,
211
+ api_version: ApiVersion = "v1",
209
212
  params: Optional[Mapping[str, Any]] = None,
210
213
  json: Any = None,
214
+ timeout: Optional[float] = None,
211
215
  ) -> Iterator[dict]:
212
216
  """Yield parsed SSE events from a streaming endpoint (e.g. POST /chats/{id}/messages).
213
217
 
214
218
  Each event is a dict like ``{"event": "message", "data": <parsed-json-or-str>}``.
215
219
  """
216
- url = path if path.startswith("http") else f"/api/v1{path if path.startswith('/') else '/' + path}"
220
+ url = _api_url(path, api_version=api_version)
217
221
  method_upper = method.upper()
222
+ request_kwargs: dict[str, Any] = {}
223
+ if timeout is not None:
224
+ request_kwargs["timeout"] = timeout
218
225
  try:
219
- with self._client.stream(method_upper, url, params=params, json=json) as r:
226
+ with self._client.stream(
227
+ method_upper,
228
+ url,
229
+ params=params,
230
+ json=json,
231
+ **request_kwargs,
232
+ ) as r:
220
233
  if r.status_code >= 400:
221
234
  body = r.read().decode("utf-8", "replace")
222
235
  self._raise_for_error(r.status_code, body)
@@ -244,7 +257,7 @@ class CodeerClient:
244
257
  method_upper,
245
258
  path,
246
259
  exc,
247
- timeout_seconds=self.timeout,
260
+ timeout_seconds=timeout if timeout is not None else self.timeout,
248
261
  ) from exc
249
262
  except httpx.RequestError as exc:
250
263
  raise self._transport_error(method_upper, path, exc) from exc
@@ -314,6 +327,18 @@ def _maybe_json(raw: str) -> Any:
314
327
  return raw
315
328
 
316
329
 
330
+ def _api_url(path: str, *, api_version: ApiVersion = "v1") -> str:
331
+ """Resolve a relative domain path without forcing every API onto one version."""
332
+ if path.startswith("http"):
333
+ return path
334
+ normalized = path if path.startswith("/") else f"/{path}"
335
+ if normalized.startswith("/api/"):
336
+ return normalized
337
+ if api_version not in ("v1", "v2"):
338
+ raise ValueError(f"Unsupported API version: {api_version}")
339
+ return f"/api/{api_version}{normalized}"
340
+
341
+
317
342
  def _workspace_names(profile: Mapping[str, Any]) -> dict[str, str]:
318
343
  names: dict[str, str] = {}
319
344
  for ws in profile.get("workspaces") or []:
@@ -76,8 +76,8 @@ def register(subparsers):
76
76
  p.add_argument("--user", default=None, help="external_user_id to associate with the history")
77
77
  p.add_argument("--message", action="append", required=True,
78
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).")
79
+ p.add_argument("--timeout", type=float, default=240.0,
80
+ help="Per-message SSE read timeout in seconds (default: 240).")
81
81
  p.add_argument("--out", default=None,
82
82
  help="Write complete create response/conversation artifact to this file; stdout stays compact.")
83
83
  p.set_defaults(func=run_create)
@@ -91,8 +91,8 @@ def register(subparsers):
91
91
  help="external_user_id override (defaults to the history's user)")
92
92
  p.add_argument("--message", action="append", required=True,
93
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).")
94
+ p.add_argument("--timeout", type=float, default=240.0,
95
+ help="Per-message SSE read timeout in seconds (default: 240).")
96
96
  p.add_argument("--out", default=None,
97
97
  help="Write complete send response/conversation artifact to this file; stdout stays compact.")
98
98
  p.set_defaults(func=run_send)
@@ -285,15 +285,19 @@ def _send_messages(
285
285
  for idx, message in enumerate(messages, 1):
286
286
  log(f"sending turn {idx}/{len(messages)}")
287
287
  try:
288
- result = chats_mod.send_published_agent_message(
288
+ stream = chats_mod.send_published_agent_message(
289
289
  client,
290
290
  chat_id=history_id,
291
291
  message=message,
292
292
  agent_id=agent_id,
293
293
  external_user_id=external_user_id,
294
- stream=False,
294
+ stream=True,
295
295
  timeout=timeout,
296
296
  )
297
+ if isinstance(stream, dict):
298
+ result = stream
299
+ else:
300
+ result = chats_mod.collect_stream(stream)
297
301
  except TransportError as exc:
298
302
  body = dict(exc.body) if isinstance(exc.body, dict) else {}
299
303
  body.update({"history_id": history_id, "turn": idx, "turn_count": len(messages)})
@@ -337,14 +341,19 @@ def run_create(args, client) -> int:
337
341
  timeout=args.timeout,
338
342
  )
339
343
 
340
- conversations = hist_mod.get_conversations(client, history_id)
344
+ chat_messages = chats_mod.list_messages(
345
+ client,
346
+ history_id,
347
+ external_user_id=args.user,
348
+ )
349
+ conversation_parts = chat_messages.get("messages") or []
341
350
  out = {
342
351
  "agent_id": agent_id,
343
352
  "history_id": history_id,
344
353
  "external_user_id": args.user,
345
354
  "url": _history_url(client, workspace_id, history_id),
346
355
  "messages": message_results,
347
- "conversations": conversations,
356
+ "conversation_parts": conversation_parts,
348
357
  }
349
358
  write_json(args.out, strip_noisy_fields(out))
350
359
  print_json({
@@ -353,7 +362,7 @@ def run_create(args, client) -> int:
353
362
  "external_user_id": args.user,
354
363
  "url": out["url"],
355
364
  "message_count": len(message_results),
356
- "turn_count": len(conversations),
365
+ "part_count": len(conversation_parts),
357
366
  "wrote_full_detail": bool(args.out),
358
367
  })
359
368
  return 0
@@ -395,14 +404,19 @@ def run_send(args, client) -> int:
395
404
  messages=args.message,
396
405
  timeout=args.timeout,
397
406
  )
398
- conversations = hist_mod.get_conversations(client, args.history_id)
407
+ chat_messages = chats_mod.list_messages(
408
+ client,
409
+ args.history_id,
410
+ external_user_id=external_user_id,
411
+ )
412
+ conversation_parts = chat_messages.get("messages") or []
399
413
  out = {
400
414
  "agent_id": agent_id,
401
415
  "history_id": args.history_id,
402
416
  "external_user_id": external_user_id,
403
417
  "url": _history_url(client, workspace_id, args.history_id),
404
418
  "messages": message_results,
405
- "conversations": conversations,
419
+ "conversation_parts": conversation_parts,
406
420
  }
407
421
  write_json(args.out, strip_noisy_fields(out))
408
422
  print_json({
@@ -411,7 +425,7 @@ def run_send(args, client) -> int:
411
425
  "external_user_id": external_user_id,
412
426
  "url": out["url"],
413
427
  "message_count": len(message_results),
414
- "turn_count": len(conversations),
428
+ "part_count": len(conversation_parts),
415
429
  "wrote_full_detail": bool(args.out),
416
430
  })
417
431
  return 0
@@ -9,21 +9,20 @@ Parsers are deliberately tolerant: missing/extra fields don't raise, casing
9
9
  mismatches (``FILE`` vs ``file``) are normalized to lowercase. They are NOT a
10
10
  schema validator.
11
11
 
12
- KEY GAP, called out here so you don't waste time:
12
+ LEGACY V1 HISTORY GAP, called out here so you don't waste time:
13
13
 
14
- Tool **arguments** (e.g. the regex passed to ``list_kb_files`` or the query
15
- passed to ``retrieve_context_objs``) and **outputs** are not persisted on the
16
- Conversation row. They flow over the WebSocket during execution and are
17
- dropped after the assistant turn is saved. What you CAN recover from a
18
- history is:
14
+ Tool **arguments** and **outputs** are not available on the legacy
15
+ ``Conversation`` row returned by ``/histories/{id}/conversations``. What you
16
+ CAN recover from that legacy history shape is:
19
17
 
20
18
  - tool name + call id (regex over ``content``: ``<tool id=...>name</tool>``)
21
19
  - per-call token usage (``meta.token_usage.tool_calls[]``)
22
20
  - retrieved primary sources (``primary_sources[]`` — the end-to-end trace)
23
21
  - assistant's final text (``content`` with tool markers stripped)
24
22
 
25
- If you need the raw tool args, you must capture them at execution time via
26
- the chat SSE stream, not from history reads.
23
+ Chat V2 structured SSE and ``GET /api/v2/chats/{id}/messages`` expose
24
+ persisted tool-call and tool-return parts. Use those surfaces when raw tool
25
+ I/O or exact event order matters.
27
26
  """
28
27
 
29
28
  from __future__ import annotations
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from codeer_cli import chats
6
+ from codeer_cli.client import TransportError
7
+
8
+
9
+ class RecordingClient:
10
+ def __init__(self) -> None:
11
+ self.calls: list[tuple[str, str, dict]] = []
12
+
13
+ def post(self, path: str, **kwargs):
14
+ self.calls.append(("POST", path, kwargs))
15
+ return {"id": 123, "name": kwargs["json"]["name"]}
16
+
17
+ def get(self, path: str, **kwargs):
18
+ self.calls.append(("GET", path, kwargs))
19
+ return {"chat_id": 123, "messages": []}
20
+
21
+
22
+ class ChatV2RequestTests(unittest.TestCase):
23
+ def test_create_uses_v2_and_always_sends_name(self) -> None:
24
+ client = RecordingClient()
25
+
26
+ result = chats.create(client, agent_id="agent-1") # type: ignore[arg-type]
27
+
28
+ self.assertEqual(result["id"], 123)
29
+ self.assertEqual(client.calls, [(
30
+ "POST",
31
+ "/chats",
32
+ {
33
+ "api_version": "v2",
34
+ "json": {"agent_id": "agent-1", "name": "CLI conversation"},
35
+ },
36
+ )])
37
+
38
+ def test_list_messages_uses_v2_pagination(self) -> None:
39
+ client = RecordingClient()
40
+
41
+ result = chats.list_messages( # type: ignore[arg-type]
42
+ client,
43
+ 123,
44
+ external_user_id="user-1",
45
+ limit=200,
46
+ )
47
+
48
+ self.assertEqual(result, {"chat_id": 123, "messages": []})
49
+ self.assertEqual(client.calls[0], (
50
+ "GET",
51
+ "/chats/123/messages",
52
+ {
53
+ "api_version": "v2",
54
+ "params": {
55
+ "limit": 200,
56
+ "offset": 0,
57
+ "external_user_id": "user-1",
58
+ },
59
+ },
60
+ ))
61
+
62
+
63
+ class ChatV2StreamTests(unittest.TestCase):
64
+ def test_collects_structured_events_and_final_text(self) -> None:
65
+ result = chats.collect_stream(iter([
66
+ {
67
+ "event": "response.part.delta",
68
+ "data": {
69
+ "type": "response.part.delta",
70
+ "response_id": "response-1",
71
+ "conversation_group_id": "group-1",
72
+ "part_kind": "text",
73
+ "delta": "Hel",
74
+ },
75
+ },
76
+ {
77
+ "event": "response.part.completed",
78
+ "data": {
79
+ "type": "response.part.completed",
80
+ "response_id": "response-1",
81
+ "part": {
82
+ "part_kind": "text",
83
+ "content": {"content": "Hello"},
84
+ },
85
+ },
86
+ },
87
+ {
88
+ "event": "response.completed",
89
+ "data": {
90
+ "type": "response.completed",
91
+ "response_id": "response-1",
92
+ "conversation_group_id": "group-1",
93
+ },
94
+ },
95
+ {"event": "message", "data": "[DONE]"},
96
+ ]))
97
+
98
+ self.assertTrue(result["completed"])
99
+ self.assertEqual(result["response_id"], "response-1")
100
+ self.assertEqual(result["conversation_group_id"], "group-1")
101
+ self.assertEqual(result["final_text"], "Hello")
102
+
103
+ def test_response_failed_is_not_retried_as_non_streaming(self) -> None:
104
+ with self.assertRaises(TransportError) as raised:
105
+ chats.collect_stream(iter([{
106
+ "event": "response.failed",
107
+ "data": {
108
+ "type": "response.failed",
109
+ "response_id": "response-1",
110
+ "message": "Model unavailable",
111
+ "code": 503,
112
+ },
113
+ }]))
114
+
115
+ self.assertIn("Model unavailable", raised.exception.message)
116
+ self.assertTrue(raised.exception.body["outcome_uncertain"])
117
+
118
+ def test_disconnect_before_completed_has_uncertain_outcome(self) -> None:
119
+ with self.assertRaises(TransportError) as raised:
120
+ chats.collect_stream(iter([{
121
+ "event": "response.part.delta",
122
+ "data": {
123
+ "type": "response.part.delta",
124
+ "response_id": "response-1",
125
+ "part_kind": "text",
126
+ "delta": "Partial",
127
+ },
128
+ }]))
129
+
130
+ self.assertIn("before response.completed", raised.exception.message)
131
+ self.assertTrue(raised.exception.body["outcome_uncertain"])
132
+
133
+
134
+ if __name__ == "__main__":
135
+ unittest.main()
@@ -22,8 +22,56 @@ class ClientTransportTests(unittest.TestCase):
22
22
  client.close()
23
23
 
24
24
  self.assertEqual(result, {"ok": True})
25
+ self.assertEqual(requests[0].url.path, "/api/v1/chats/1/messages")
25
26
  self.assertEqual(requests[0].extensions["timeout"]["read"], 120.0)
26
27
 
28
+ def test_request_can_target_v2_without_changing_v1_default(self) -> None:
29
+ requests: list[httpx.Request] = []
30
+
31
+ def handler(request: httpx.Request) -> httpx.Response:
32
+ requests.append(request)
33
+ return httpx.Response(200, json={"data": {"id": 1}, "error_code": 0})
34
+
35
+ client = self._client(handler)
36
+ try:
37
+ result = client.post("/chats", api_version="v2", json={"name": "Test"})
38
+ finally:
39
+ client.close()
40
+
41
+ self.assertEqual(result, {"id": 1})
42
+ self.assertEqual(requests[0].url.path, "/api/v2/chats")
43
+
44
+ def test_stream_can_target_v2_and_forward_read_timeout(self) -> None:
45
+ requests: list[httpx.Request] = []
46
+
47
+ def handler(request: httpx.Request) -> httpx.Response:
48
+ requests.append(request)
49
+ return httpx.Response(
50
+ 200,
51
+ text=(
52
+ "event: response.completed\n"
53
+ 'data: {"type":"response.completed","response_id":"r1"}\n\n'
54
+ "data: [DONE]\n\n"
55
+ ),
56
+ )
57
+
58
+ client = self._client(handler)
59
+ try:
60
+ events = list(client.stream_sse(
61
+ "POST",
62
+ "/chats/1/messages",
63
+ api_version="v2",
64
+ json={"message": "Hi", "stream": True},
65
+ timeout=240.0,
66
+ ))
67
+ finally:
68
+ client.close()
69
+
70
+ self.assertEqual(requests[0].url.path, "/api/v2/chats/1/messages")
71
+ self.assertEqual(requests[0].extensions["timeout"]["read"], 240.0)
72
+ self.assertEqual(events[0]["event"], "response.completed")
73
+ self.assertEqual(events[0]["data"]["response_id"], "r1")
74
+
27
75
  def test_timeout_becomes_transport_error_with_uncertain_write_outcome(self) -> None:
28
76
  def handler(request: httpx.Request) -> httpx.Response:
29
77
  raise httpx.ReadTimeout("response took too long", request=request)
@@ -24,9 +24,23 @@ class FakeClient:
24
24
  self.calls.append((path, kwargs))
25
25
  return {"ok": True}
26
26
 
27
+ def stream_sse(self, method: str, path: str, **kwargs):
28
+ self.calls.append((path, {"method": method, **kwargs}))
29
+ return iter([
30
+ {
31
+ "event": "response.completed",
32
+ "data": {
33
+ "type": "response.completed",
34
+ "response_id": "response-1",
35
+ "conversation_group_id": "group-1",
36
+ },
37
+ },
38
+ {"event": "message", "data": "[DONE]"},
39
+ ])
40
+
27
41
 
28
42
  class HistorySendTests(unittest.TestCase):
29
- def test_chat_send_forwards_response_timeout(self) -> None:
43
+ def test_chat_send_uses_v2_stream_and_forwards_timeout(self) -> None:
30
44
  client = FakeClient()
31
45
 
32
46
  chats_mod.send_published_agent_message(
@@ -38,6 +52,9 @@ class HistorySendTests(unittest.TestCase):
38
52
  )
39
53
 
40
54
  self.assertEqual(client.calls[0][0], "/chats/18649/messages")
55
+ self.assertEqual(client.calls[0][1]["method"], "POST")
56
+ self.assertEqual(client.calls[0][1]["api_version"], "v2")
57
+ self.assertEqual(client.calls[0][1]["json"]["stream"], True)
41
58
  self.assertEqual(client.calls[0][1]["timeout"], 120.0)
42
59
 
43
60
  def test_run_send_resolves_agent_and_appends_to_existing_history(self) -> None:
@@ -66,32 +83,55 @@ class HistorySendTests(unittest.TestCase):
66
83
  },
67
84
  ),
68
85
  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
- ),
86
+ history_cmd.chats_mod,
87
+ "list_messages",
88
+ return_value={"chat_id": 18649, "messages": [
89
+ {"part_kind": "user-prompt"},
90
+ {"part_kind": "text"},
91
+ ]},
92
+ ) as list_messages,
78
93
  patch.object(
79
94
  history_cmd.chats_mod,
80
95
  "send_published_agent_message",
81
- return_value={"conversation_id": 4},
96
+ return_value=iter([
97
+ {
98
+ "event": "response.part.delta",
99
+ "data": {
100
+ "type": "response.part.delta",
101
+ "response_id": "response-1",
102
+ "conversation_group_id": "group-1",
103
+ "part_kind": "text",
104
+ "delta": "Final",
105
+ },
106
+ },
107
+ {
108
+ "event": "response.completed",
109
+ "data": {
110
+ "type": "response.completed",
111
+ "response_id": "response-1",
112
+ "conversation_group_id": "group-1",
113
+ },
114
+ },
115
+ {"event": "message", "data": "[DONE]"},
116
+ ]),
82
117
  ) as send,
83
118
  redirect_stdout(StringIO()),
84
119
  ):
85
120
  result = history_cmd.run_send(args, client)
86
121
 
87
122
  self.assertEqual(result, 0)
123
+ list_messages.assert_called_once_with(
124
+ client,
125
+ 18649,
126
+ external_user_id="user-1",
127
+ )
88
128
  send.assert_called_once_with(
89
129
  client,
90
130
  chat_id=18649,
91
131
  message="Use the recommended options",
92
132
  agent_id="agent-1",
93
133
  external_user_id="user-1",
94
- stream=False,
134
+ stream=True,
95
135
  timeout=120.0,
96
136
  )
97
137
 
@@ -26,7 +26,7 @@ wheels = [
26
26
 
27
27
  [[package]]
28
28
  name = "codeer-cli"
29
- version = "0.1.10"
29
+ version = "0.1.11"
30
30
  source = { editable = "." }
31
31
  dependencies = [
32
32
  { name = "httpx" },
@@ -1,87 +0,0 @@
1
- """Chat creation and SSE-streamed agent responses — the Live Test surface.
2
-
3
- The send_message() path is the main dogfood driver: open a chat against a
4
- specific agent version (unpublished draft is fine) and consume the SSE stream
5
- to get tool calls, reasoning, and final text back.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- from typing import Any, Iterator, List, Optional
11
-
12
- from .client import CodeerClient
13
-
14
-
15
- def create(
16
- client: CodeerClient,
17
- *,
18
- agent_id: str,
19
- title: Optional[str] = None,
20
- external_user_id: Optional[str] = None,
21
- ) -> dict:
22
- body: dict[str, Any] = {"agent_id": agent_id}
23
- if title is not None:
24
- body["name"] = title
25
- if external_user_id is not None:
26
- body["external_user_id"] = external_user_id
27
- return client.post("/chats", json=body)
28
-
29
-
30
- def send_published_agent_message(
31
- client: CodeerClient,
32
- *,
33
- chat_id: int,
34
- message: str,
35
- agent_id: str,
36
- external_user_id: Optional[str] = None,
37
- attachment_ids: Optional[List[str]] = None,
38
- stream: bool = False,
39
- timeout: Optional[float] = None,
40
- ) -> Iterator[dict] | dict:
41
- """Send a user message through the API-key external chat flow.
42
-
43
- API-key chat endpoints use the agent's published version. They accept
44
- ``agent_id`` rather than ``agent_history_id``.
45
- """
46
- body: dict[str, Any] = {"message": message, "agent_id": agent_id, "stream": stream}
47
- if external_user_id is not None:
48
- body["external_user_id"] = external_user_id
49
- if attachment_ids:
50
- body["attached_file_uuids"] = attachment_ids
51
-
52
- path = f"/chats/{chat_id}/messages"
53
- if stream:
54
- return client.stream_sse("POST", path, json=body)
55
- return client.post(path, json=body, timeout=timeout)
56
-
57
-
58
- def send_message(
59
- client: CodeerClient,
60
- *,
61
- chat_id: int,
62
- message: str,
63
- agent_history_id: str,
64
- attachment_ids: Optional[List[str]] = None,
65
- stream: bool = True,
66
- ) -> Iterator[dict] | dict:
67
- """Send a user message; yield SSE events (if stream=True) or return the final payload.
68
-
69
- ``agent_history_id`` is required — this is how you pin Live Test to a specific
70
- (possibly unpublished) agent version without affecting production users.
71
- """
72
- body: dict[str, Any] = {"message": message, "agent_history_id": agent_history_id}
73
- if attachment_ids:
74
- body["attachment_ids"] = attachment_ids
75
-
76
- path = f"/chats/{chat_id}/messages"
77
- if stream:
78
- return client.stream_sse("POST", path, json=body)
79
- return client.post(path, json=body)
80
-
81
-
82
- def list_messages(client: CodeerClient, chat_id: int) -> list[dict]:
83
- return client.get(f"/chats/{chat_id}/messages")
84
-
85
-
86
- def list_chats(client: CodeerClient) -> list[dict]:
87
- return client.get("/chats")
File without changes