codeer-cli 0.1.10__py3-none-any.whl → 0.1.12__py3-none-any.whl

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/chats.py CHANGED
@@ -1,15 +1,10 @@
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
- """
1
+ """Chat V2 creation, structured SSE responses, and persisted message reads."""
7
2
 
8
3
  from __future__ import annotations
9
4
 
10
5
  from typing import Any, Iterator, List, Optional
11
6
 
12
- from .client import CodeerClient
7
+ from .client import CodeerClient, TransportError
13
8
 
14
9
 
15
10
  def create(
@@ -19,12 +14,13 @@ def create(
19
14
  title: Optional[str] = None,
20
15
  external_user_id: Optional[str] = None,
21
16
  ) -> dict:
22
- body: dict[str, Any] = {"agent_id": agent_id}
23
- if title is not None:
24
- body["name"] = title
17
+ body: dict[str, Any] = {
18
+ "agent_id": agent_id,
19
+ "name": title or "CLI conversation",
20
+ }
25
21
  if external_user_id is not None:
26
22
  body["external_user_id"] = external_user_id
27
- return client.post("/chats", json=body)
23
+ return client.post("/chats", api_version="v2", json=body)
28
24
 
29
25
 
30
26
  def send_published_agent_message(
@@ -35,7 +31,7 @@ def send_published_agent_message(
35
31
  agent_id: str,
36
32
  external_user_id: Optional[str] = None,
37
33
  attachment_ids: Optional[List[str]] = None,
38
- stream: bool = False,
34
+ stream: bool = True,
39
35
  timeout: Optional[float] = None,
40
36
  ) -> Iterator[dict] | dict:
41
37
  """Send a user message through the API-key external chat flow.
@@ -51,8 +47,14 @@ def send_published_agent_message(
51
47
 
52
48
  path = f"/chats/{chat_id}/messages"
53
49
  if stream:
54
- return client.stream_sse("POST", path, json=body)
55
- return client.post(path, json=body, timeout=timeout)
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)
56
58
 
57
59
 
58
60
  def send_message(
@@ -63,25 +65,173 @@ def send_message(
63
65
  agent_history_id: str,
64
66
  attachment_ids: Optional[List[str]] = None,
65
67
  stream: bool = True,
68
+ timeout: Optional[float] = None,
66
69
  ) -> Iterator[dict] | dict:
67
- """Send a user message; yield SSE events (if stream=True) or return the final payload.
70
+ """Use legacy Chat V1 to pin an unpublished agent version.
68
71
 
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.
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.
71
75
  """
72
- body: dict[str, Any] = {"message": message, "agent_history_id": agent_history_id}
76
+ body: dict[str, Any] = {
77
+ "message": message,
78
+ "agent_history_id": agent_history_id,
79
+ "stream": stream,
80
+ }
73
81
  if attachment_ids:
74
82
  body["attachment_ids"] = attachment_ids
75
83
 
76
84
  path = f"/chats/{chat_id}/messages"
77
85
  if stream:
78
- return client.stream_sse("POST", path, json=body)
79
- return client.post(path, json=body)
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
+
80
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
+ """Return every client-visible Chat V2 part for a persisted chat.
81
190
 
82
- def list_messages(client: CodeerClient, chat_id: int) -> list[dict]:
83
- return client.get(f"/chats/{chat_id}/messages")
191
+ ``limit`` is the per-request page size, not a cap on the returned history.
192
+ The HTTP client unwraps Codeer's response envelope (and therefore its
193
+ pagination object), so exhaustion is detected from the number of messages
194
+ returned by each page. A final empty request is possible when the total is
195
+ an exact multiple of ``limit``.
196
+ """
197
+ if limit <= 0:
198
+ raise ValueError("limit must be greater than zero")
199
+
200
+ offset = 0
201
+ result: dict[str, Any] | None = None
202
+ messages: list[dict] = []
203
+ while True:
204
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
205
+ if external_user_id is not None:
206
+ params["external_user_id"] = external_user_id
207
+ page = client.get(
208
+ f"/chats/{chat_id}/messages",
209
+ api_version="v2",
210
+ params=params,
211
+ )
212
+ if not isinstance(page, dict):
213
+ raise ValueError("Chat V2 messages response must be an object")
214
+
215
+ page_messages = page.get("messages") or []
216
+ if not isinstance(page_messages, list):
217
+ raise ValueError("Chat V2 messages response must contain a messages list")
218
+ if result is None:
219
+ result = dict(page)
220
+ else:
221
+ for key, value in page.items():
222
+ if key != "messages" and value is not None:
223
+ result[key] = value
224
+ messages.extend(page_messages)
225
+
226
+ if len(page_messages) < limit:
227
+ break
228
+ offset += len(page_messages)
229
+
230
+ assert result is not None
231
+ result["messages"] = messages
232
+ return result
84
233
 
85
234
 
86
235
  def list_chats(client: CodeerClient) -> list[dict]:
236
+ """List chats through the legacy v1 endpoint; Chat V2 has no list route."""
87
237
  return client.get("/chats")
codeer_cli/client.py CHANGED
@@ -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 []:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import json
3
4
  import math
4
5
  import os
5
6
 
@@ -47,13 +48,13 @@ def register(subparsers):
47
48
  # codeer history conversations <id>
48
49
  p = sub.add_parser(
49
50
  "conversations",
50
- help="Summarize all turns in a history. Complete turns/tool payloads require --out.",
51
+ help="Summarize all Chat V2 parts in a history. Complete parts/tool payloads require --out.",
51
52
  )
52
53
  p.add_argument("history_id", type=int)
53
54
  p.add_argument("--full", action="store_true",
54
- help="Require --out and write complete stripped turns there; stdout remains a summary.")
55
+ help="Require --out and include longer stdout previews; the artifact is always complete.")
55
56
  p.add_argument("--out", default=None,
56
- help="Write complete stripped turns to this file. Use for debugging or eval-case extraction.")
57
+ help="Write every unmodified client-visible Chat V2 part to this file.")
57
58
  p.set_defaults(func=run_conversations)
58
59
 
59
60
  # codeer history negative-feedback
@@ -76,8 +77,8 @@ def register(subparsers):
76
77
  p.add_argument("--user", default=None, help="external_user_id to associate with the history")
77
78
  p.add_argument("--message", action="append", required=True,
78
79
  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).")
80
+ p.add_argument("--timeout", type=float, default=240.0,
81
+ help="Per-message SSE read timeout in seconds (default: 240).")
81
82
  p.add_argument("--out", default=None,
82
83
  help="Write complete create response/conversation artifact to this file; stdout stays compact.")
83
84
  p.set_defaults(func=run_create)
@@ -91,8 +92,8 @@ def register(subparsers):
91
92
  help="external_user_id override (defaults to the history's user)")
92
93
  p.add_argument("--message", action="append", required=True,
93
94
  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).")
95
+ p.add_argument("--timeout", type=float, default=240.0,
96
+ help="Per-message SSE read timeout in seconds (default: 240).")
96
97
  p.add_argument("--out", default=None,
97
98
  help="Write complete send response/conversation artifact to this file; stdout stays compact.")
98
99
  p.set_defaults(func=run_send)
@@ -155,18 +156,33 @@ def _history_summary(row: dict, *, full: bool = False) -> dict:
155
156
  return out
156
157
 
157
158
 
158
- def _turn_summary(turn: dict, idx: int, *, full: bool = False) -> dict:
159
- content = turn.get("content") or turn.get("message") or ""
160
- tool_calls = turn.get("tool_calls") or turn.get("tools") or []
159
+ def _part_summary(part: dict, idx: int, *, full: bool = False) -> dict:
160
+ raw_content = part.get("content")
161
+ if isinstance(raw_content, dict):
162
+ content_value = raw_content.get("content")
163
+ if content_value is None and part.get("part_kind") == "tool-call":
164
+ content_value = raw_content.get("args")
165
+ else:
166
+ content_value = raw_content
167
+ if isinstance(content_value, str):
168
+ content = content_value
169
+ elif content_value is None:
170
+ content = ""
171
+ else:
172
+ content = json.dumps(content_value, ensure_ascii=False, default=str)
161
173
  row = {
162
- "turn_idx": idx,
163
- "id": turn.get("id"),
164
- "role": turn.get("role"),
165
- "created_at": turn.get("created_at"),
174
+ "part_idx": idx,
175
+ "id": part.get("id"),
176
+ "conversation_id": part.get("conversation_id"),
177
+ "conversation_group_id": part.get("conversation_group_id"),
178
+ "sequence": part.get("sequence"),
179
+ "part_kind": part.get("part_kind"),
180
+ "source": part.get("source"),
181
+ "created_at": part.get("created_at"),
166
182
  "content_preview": truncate(content, 600 if full else 240),
167
183
  "content_chars": len(content),
168
- "tool_call_count": len(tool_calls) if isinstance(tool_calls, list) else None,
169
- "feedback_count": len(turn.get("feedbacks") or []),
184
+ "attachment_count": len(part.get("attached_files") or []),
185
+ "feedback_count": len(part.get("feedbacks") or []),
170
186
  }
171
187
  if full:
172
188
  row["feedbacks"] = [
@@ -175,7 +191,7 @@ def _turn_summary(turn: dict, idx: int, *, full: bool = False) -> dict:
175
191
  "tag": fb.get("tag"),
176
192
  "content_preview": truncate(fb.get("content") or "", 240),
177
193
  }
178
- for fb in (turn.get("feedbacks") or [])
194
+ for fb in (part.get("feedbacks") or [])
179
195
  ]
180
196
  return row
181
197
 
@@ -231,16 +247,24 @@ def run_get(args, client) -> int:
231
247
 
232
248
 
233
249
  def run_conversations(args, client) -> int:
234
- result = hist_mod.get_conversations(client, args.history_id)
250
+ result = chats_mod.list_messages(client, args.history_id)
251
+ parts = result.get("messages") or []
235
252
  if args.full and not args.out:
236
253
  log("error: full conversation payloads are unbounded; pass --out <path>")
237
254
  return 2
238
- write_json(args.out, strip_noisy_fields(result))
255
+ write_json(args.out, result)
256
+ group_ids = {
257
+ p.get("conversation_group_id")
258
+ for p in parts
259
+ if p.get("conversation_group_id")
260
+ }
239
261
  print_json({
240
262
  "history_id": args.history_id,
241
- "turn_count": len(result),
263
+ "turn_count": len(group_ids),
264
+ "part_count": len(parts),
242
265
  "wrote_full_detail": bool(args.out),
243
- "turns": [_turn_summary(t, i, full=args.full) for i, t in enumerate(result)],
266
+ "stdout_is_summary": True,
267
+ "parts": [_part_summary(p, i, full=args.full) for i, p in enumerate(parts)],
244
268
  })
245
269
  return 0
246
270
 
@@ -285,15 +309,19 @@ def _send_messages(
285
309
  for idx, message in enumerate(messages, 1):
286
310
  log(f"sending turn {idx}/{len(messages)}")
287
311
  try:
288
- result = chats_mod.send_published_agent_message(
312
+ stream = chats_mod.send_published_agent_message(
289
313
  client,
290
314
  chat_id=history_id,
291
315
  message=message,
292
316
  agent_id=agent_id,
293
317
  external_user_id=external_user_id,
294
- stream=False,
318
+ stream=True,
295
319
  timeout=timeout,
296
320
  )
321
+ if isinstance(stream, dict):
322
+ result = stream
323
+ else:
324
+ result = chats_mod.collect_stream(stream)
297
325
  except TransportError as exc:
298
326
  body = dict(exc.body) if isinstance(exc.body, dict) else {}
299
327
  body.update({"history_id": history_id, "turn": idx, "turn_count": len(messages)})
@@ -337,23 +365,28 @@ def run_create(args, client) -> int:
337
365
  timeout=args.timeout,
338
366
  )
339
367
 
340
- conversations = hist_mod.get_conversations(client, history_id)
368
+ chat_messages = chats_mod.list_messages(
369
+ client,
370
+ history_id,
371
+ external_user_id=args.user,
372
+ )
373
+ conversation_parts = chat_messages.get("messages") or []
341
374
  out = {
342
375
  "agent_id": agent_id,
343
376
  "history_id": history_id,
344
377
  "external_user_id": args.user,
345
378
  "url": _history_url(client, workspace_id, history_id),
346
379
  "messages": message_results,
347
- "conversations": conversations,
380
+ "conversation_parts": conversation_parts,
348
381
  }
349
- write_json(args.out, strip_noisy_fields(out))
382
+ write_json(args.out, out)
350
383
  print_json({
351
384
  "agent_id": agent_id,
352
385
  "history_id": history_id,
353
386
  "external_user_id": args.user,
354
387
  "url": out["url"],
355
388
  "message_count": len(message_results),
356
- "turn_count": len(conversations),
389
+ "part_count": len(conversation_parts),
357
390
  "wrote_full_detail": bool(args.out),
358
391
  })
359
392
  return 0
@@ -395,23 +428,28 @@ def run_send(args, client) -> int:
395
428
  messages=args.message,
396
429
  timeout=args.timeout,
397
430
  )
398
- conversations = hist_mod.get_conversations(client, args.history_id)
431
+ chat_messages = chats_mod.list_messages(
432
+ client,
433
+ args.history_id,
434
+ external_user_id=external_user_id,
435
+ )
436
+ conversation_parts = chat_messages.get("messages") or []
399
437
  out = {
400
438
  "agent_id": agent_id,
401
439
  "history_id": args.history_id,
402
440
  "external_user_id": external_user_id,
403
441
  "url": _history_url(client, workspace_id, args.history_id),
404
442
  "messages": message_results,
405
- "conversations": conversations,
443
+ "conversation_parts": conversation_parts,
406
444
  }
407
- write_json(args.out, strip_noisy_fields(out))
445
+ write_json(args.out, out)
408
446
  print_json({
409
447
  "agent_id": agent_id,
410
448
  "history_id": args.history_id,
411
449
  "external_user_id": external_user_id,
412
450
  "url": out["url"],
413
451
  "message_count": len(message_results),
414
- "turn_count": len(conversations),
452
+ "part_count": len(conversation_parts),
415
453
  "wrote_full_detail": bool(args.out),
416
454
  })
417
455
  return 0
codeer_cli/histories.py CHANGED
@@ -94,11 +94,10 @@ def list_negative_feedback_turns(
94
94
  the source channel — usually "system"). Pass the desired sentiment(s)
95
95
  in ``feedback_types``.
96
96
 
97
- Cost: O(N histories) network calls — one ``/histories/{id}/conversations``
98
- per history. Filter aggressively via ``exclude_users`` and ``limit``
99
- before invoking on a busy agent.
97
+ Cost: O(N histories) paginated Chat V2 reads. Filter aggressively via
98
+ ``exclude_users`` and ``limit`` before invoking on a busy agent.
100
99
  """
101
- from .parse import strip_tool_markers # local import to avoid cycle
100
+ from . import chats
102
101
 
103
102
  type_set = {t.lower() for t in feedback_types}
104
103
  histories = list(
@@ -115,33 +114,42 @@ def list_negative_feedback_turns(
115
114
  if hid is None:
116
115
  continue
117
116
  try:
118
- convs = get_conversations(client, hid)
117
+ parts = (chats.list_messages(client, hid).get("messages") or [])
119
118
  except Exception:
120
119
  continue
121
- for i, c in enumerate(convs):
122
- if (c.get("role") or "") != "assistant":
120
+
121
+ turn_indexes: dict[str, int] = {}
122
+ user_messages: dict[str, str] = {}
123
+ next_turn_idx = 0
124
+ for part_idx, part in enumerate(parts):
125
+ group_id = str(part.get("conversation_group_id") or "")
126
+ part_kind = part.get("part_kind") or ""
127
+ if group_id and group_id not in turn_indexes:
128
+ turn_indexes[group_id] = next_turn_idx
129
+ next_turn_idx += 1
130
+ if part_kind == "user-prompt":
131
+ user_messages[group_id] = _part_text(part)
132
+ continue
133
+ if part_kind != "text":
123
134
  continue
124
- fbs = c.get("feedbacks") or []
135
+ fbs = part.get("feedbacks") or []
125
136
  for fb in fbs:
126
137
  ftype = (fb.get("type") or "").lower()
127
138
  if ftype not in type_set:
128
139
  continue
129
- # Find the most recent user turn before this assistant turn.
130
- prior_user = ""
131
- for j in range(i - 1, -1, -1):
132
- if (convs[j].get("role") or "") == "user":
133
- prior_user = (convs[j].get("content") or "")[:user_excerpt_chars]
134
- break
135
140
  out.append({
136
141
  "history_id": hid,
137
142
  "history_title": h.get("name") or h.get("title") or "",
138
143
  "external_user_id": h.get("external_user_id") or "",
139
144
  "created_at": h.get("created_at"),
140
- "turn_idx": i,
145
+ "turn_idx": turn_indexes.get(group_id),
146
+ "part_idx": part_idx,
147
+ "conversation_group_id": group_id or None,
148
+ "conversation_part_id": part.get("id"),
141
149
  "feedback_type": ftype,
142
150
  "feedback_text": fb.get("content") or "",
143
- "user_message": prior_user,
144
- "assistant_excerpt": strip_tool_markers(c.get("content") or "")[:assistant_excerpt_chars],
151
+ "user_message": user_messages.get(group_id, "")[:user_excerpt_chars],
152
+ "assistant_excerpt": _part_text(part)[:assistant_excerpt_chars],
145
153
  })
146
154
  return out
147
155
 
@@ -151,6 +159,20 @@ def get(client: CodeerClient, history_id: int) -> dict:
151
159
 
152
160
 
153
161
  def get_conversations(client: CodeerClient, history_id: int) -> list[dict]:
154
- """Return all conversation turns for a history — includes tool calls and reasoning."""
162
+ """Return legacy V1 conversation rows.
163
+
164
+ Prefer ``codeer_cli.chats.list_messages`` whenever exact Chat V2 parts,
165
+ tool inputs/results, or event order matter.
166
+ """
155
167
  return client.get(f"/external/histories/{history_id}/conversations")
156
168
 
169
+
170
+ def _part_text(part: dict) -> str:
171
+ content = part.get("content")
172
+ if isinstance(content, dict):
173
+ value = content.get("content")
174
+ else:
175
+ value = content
176
+ if value is None:
177
+ return ""
178
+ return value if isinstance(value, str) else str(value)
codeer_cli/parse.py CHANGED
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeer-cli
3
- Version: 0.1.10
3
+ Version: 0.1.12
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,18 @@ 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 conversations <history-id> --out .codeer/current/history-<history-id>.json
176
+ codeer history create --agent <agent-id> --message "Review this plan" --timeout 240
177
+ codeer history send <history-id> --message "Use the recommended options" --timeout 240
177
178
  codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
178
179
  ```
179
180
 
180
181
  `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.
182
+ They use Chat V2 structured SSE with `stream: true`; their per-message read
183
+ timeout defaults to 240 seconds. Success requires a `response.completed`
184
+ event. If the stream times out, reports `response.failed`, or disconnects
185
+ early, inspect the history before retrying: the server may already have
186
+ persisted the turn.
184
187
 
185
188
  Eval case label commands always operate on the active API-key workspace. They
186
189
  do not accept a workspace override; switch CLI profiles to target another
@@ -194,6 +197,11 @@ Flags:
194
197
  for raw eval results, full conversation turns, full rubric matrices, and
195
198
  other data that can grow with cases, versions, or turns.
196
199
 
200
+ `history conversations` reads Chat V2 parts and follows all pages
201
+ automatically. Its stdout is still a bounded summary; the `--out` artifact is
202
+ the complete client-visible history, including tool calls/results,
203
+ attachments, interactions, feedback, and passthrough metadata.
204
+
197
205
  Avoid piping large raw JSON directly into agent chat. Prefer `--out`, then ask
198
206
  the coding agent to inspect targeted summaries, IDs, failing cases, or selected
199
207
  snippets from the saved file.
@@ -1,25 +1,25 @@
1
1
  codeer_cli/__init__.py,sha256=z3rkUdVCt11c8QE3-YFkG5cFObM6QhPe2yKugA8k3aA,1915
2
2
  codeer_cli/_validate.py,sha256=lqMwkhsBy1g0o5CINBp1HdBCjMawgUaEsZygjJaAPL0,6943
3
3
  codeer_cli/agents.py,sha256=HWfz6QgU4wn07df5s3twxVJfAhb0vLJHIfxTjvDMUzU,6045
4
- codeer_cli/chats.py,sha256=pww_AVByTh_AT2Td0gj2OomJrcw7D-cjDIpQ0ZQOlrM,2728
4
+ codeer_cli/chats.py,sha256=Vg1DIu8fT6mPyPpfqLpExKvXhdZEWCyJGPvalQy3BXg,7989
5
5
  codeer_cli/cli.py,sha256=ghZMn7LUxDDvb3f82mDYW5hIFLk7dtlrCrGGP40Gygw,4637
6
- codeer_cli/client.py,sha256=x-5sx06dgS6P8siKOTCVNM8JlBST5e6PsOD83KKkpN8,12361
6
+ codeer_cli/client.py,sha256=apUycjK-OvHEquAj58FqOy8FHmHjA6K9dScaYQ4M-VU,13175
7
7
  codeer_cli/constants.py,sha256=D1pV3wCoqYybrKGKeoupYjjFWLfaFviKp1yL7oh6Qso,2323
8
8
  codeer_cli/eval_.py,sha256=2Tz4qokctocZ0fqLYL8Sk9UV8yJ7p-9zYiUFljsRRCs,17478
9
- codeer_cli/histories.py,sha256=tk28git_peX4x703CIDU8u72JtlGaytyrtlHfxlK-7A,5979
9
+ codeer_cli/histories.py,sha256=WvpqvuprPc1nJJCTElyM_eoUabyooACfmRCwYlcvPnI,6637
10
10
  codeer_cli/kb.py,sha256=Ad4h65NByq5Rq5BTeMghLTKlWRhmOC2jxL0BaTGX3EM,10631
11
11
  codeer_cli/models.py,sha256=Ama9fx8hyRMdysiUNA3Ty0sYIYwzFDkOcAZg-id3-vQ,362
12
- codeer_cli/parse.py,sha256=qrjZn0MUTjGfucp4cwxy8Pt7WS-0x15kK5F7kWTY8Ps,21818
12
+ codeer_cli/parse.py,sha256=-txMtIxPfeg_i4McEskVKxo8p4438gKS-sFTI38RoBs,21772
13
13
  codeer_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
14
  codeer_cli/commands/_util.py,sha256=X9-9cYgnBYz94hVG4Pe9SsI97DHNv3HE5_7GlPMn93I,1708
15
15
  codeer_cli/commands/agent.py,sha256=6qtYcQYgdJE26V6Nw6parB_3RYAYXcOpPE9iJLuLGHo,15888
16
16
  codeer_cli/commands/check.py,sha256=lTxolx1mIJ8jldPhJ5FXqie9nbCLVOO-sDPOHTSy1-w,3817
17
17
  codeer_cli/commands/eval_cmd.py,sha256=Zlt_YIA3D5-Y8WL1HZ6PxZgL-TcwFTDoXvO2gv7yfOM,71989
18
- codeer_cli/commands/history.py,sha256=bVBlCMStTvTxG23bggASHkHKuI0RMPdiXjcrs3U04yw,16081
18
+ codeer_cli/commands/history.py,sha256=2p9VuPwyexr7Q2FFIBSuv9hNzn7VdIMa-AtvtP-8LvI,17253
19
19
  codeer_cli/commands/kb.py,sha256=kVEinBVM6NN8_0djOqIQErh46dLmArwFngXMNzvGeAI,28345
20
20
  codeer_cli/commands/model.py,sha256=91LDo_YeHi3POZV7KTCkoCcgtK5p87LwrHX1lZgfCps,1698
21
21
  codeer_cli/commands/profile.py,sha256=IdlXC_6cqobsfN3JRrAnt-1OgBUsIFneS9QtR4Un6Kc,6521
22
- codeer_cli-0.1.10.dist-info/METADATA,sha256=OFyEgLMBpMJ2cUkG58dei40u1BBtmSEF8HdPeyGbCek,7396
23
- codeer_cli-0.1.10.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
24
- codeer_cli-0.1.10.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
25
- codeer_cli-0.1.10.dist-info/RECORD,,
22
+ codeer_cli-0.1.12.dist-info/METADATA,sha256=CFrY9ndSNGm4K5UC0A9tZyCXAQPOgWMO5CcGMMNRda0,7885
23
+ codeer_cli-0.1.12.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
24
+ codeer_cli-0.1.12.dist-info/entry_points.txt,sha256=-nXIrlm5SR5r7gg3y8AS0tN66MwmvNHsrlwLNQNGD50,47
25
+ codeer_cli-0.1.12.dist-info/RECORD,,