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.
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/API_REFERENCE.md +34 -25
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/PKG-INFO +8 -6
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/README.md +7 -5
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/pyproject.toml +1 -1
- codeer_cli-0.1.11/src/codeer_cli/chats.py +201 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/client.py +30 -5
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/history.py +26 -12
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/parse.py +7 -8
- codeer_cli-0.1.11/tests/test_chats_v2.py +135 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_client_transport.py +48 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_history_send.py +52 -12
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/uv.lock +1 -1
- codeer_cli-0.1.10/src/codeer_cli/chats.py +0 -87
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/.gitignore +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/__init__.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/_validate.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/agents.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/cli.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/__init__.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/_util.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/agent.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/check.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/eval_cmd.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/kb.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/model.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/commands/profile.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/constants.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/eval_.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/histories.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/kb.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/src/codeer_cli/models.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_agent_handoff.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_eval_labels.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_eval_pairs.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_kb_nodes.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_kb_ranges.py +0 -0
- {codeer_cli-0.1.10 → codeer_cli-0.1.11}/tests/test_models.py +0 -0
- {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`).
|
|
7
|
-
|
|
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
|
|
148
|
-
| `POST /chats/{chat_id}/messages` | Send a message
|
|
149
|
-
| `GET /chats/{chat_id}/messages` | Read
|
|
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`
|
|
154
|
-
for the apply → test → publish workflow. Pass the draft
|
|
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
|
|
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`.
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
— there is no `tool` role row.
|
|
468
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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.
|
|
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
|
|
176
|
-
codeer history send <history-id> --message "Use the recommended options" --timeout
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
158
|
-
codeer history send <history-id> --message "Use the recommended options" --timeout
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
@@ -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 =
|
|
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 =
|
|
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(
|
|
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=
|
|
80
|
-
help="Per-message
|
|
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=
|
|
95
|
-
help="Per-message
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
12
|
+
LEGACY V1 HISTORY GAP, called out here so you don't waste time:
|
|
13
13
|
|
|
14
|
-
Tool **arguments**
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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.
|
|
70
|
-
"
|
|
71
|
-
return_value=[
|
|
72
|
-
{"
|
|
73
|
-
{"
|
|
74
|
-
|
|
75
|
-
|
|
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=
|
|
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=
|
|
134
|
+
stream=True,
|
|
95
135
|
timeout=120.0,
|
|
96
136
|
)
|
|
97
137
|
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|