codeer-cli 0.1.11__tar.gz → 0.1.12__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.11 → codeer_cli-0.1.12}/API_REFERENCE.md +7 -4
  2. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/PKG-INFO +7 -1
  3. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/README.md +6 -0
  4. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/pyproject.toml +1 -1
  5. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/chats.py +44 -8
  6. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/history.py +43 -19
  7. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/histories.py +40 -18
  8. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_chats_v2.py +53 -0
  9. codeer_cli-0.1.12/tests/test_history_read.py +132 -0
  10. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/uv.lock +1 -1
  11. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/.gitignore +0 -0
  12. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/__init__.py +0 -0
  13. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/_validate.py +0 -0
  14. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/agents.py +0 -0
  15. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/cli.py +0 -0
  16. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/client.py +0 -0
  17. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/__init__.py +0 -0
  18. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/_util.py +0 -0
  19. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/agent.py +0 -0
  20. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/check.py +0 -0
  21. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/eval_cmd.py +0 -0
  22. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/kb.py +0 -0
  23. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/model.py +0 -0
  24. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/commands/profile.py +0 -0
  25. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/constants.py +0 -0
  26. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/eval_.py +0 -0
  27. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/kb.py +0 -0
  28. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/models.py +0 -0
  29. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/src/codeer_cli/parse.py +0 -0
  30. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_agent_handoff.py +0 -0
  31. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_client_transport.py +0 -0
  32. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_eval_labels.py +0 -0
  33. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_eval_pairs.py +0 -0
  34. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_history_send.py +0 -0
  35. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_kb_nodes.py +0 -0
  36. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_kb_ranges.py +0 -0
  37. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_models.py +0 -0
  38. {codeer_cli-0.1.11 → codeer_cli-0.1.12}/tests/test_util.py +0 -0
@@ -27,6 +27,9 @@ need a default agent.
27
27
  - `/histories` uses **`limit` + `offset`** (NOT `page` / `page_size`).
28
28
  Default in `histories.list()` is `limit=500`. Backend hard-cap may be
29
29
  lower — check the response length.
30
+ - `/api/v2/chats/{id}/messages` also uses `limit` + `offset`.
31
+ `chats.list_messages()` follows pages until exhaustion; its `limit` argument
32
+ is a page size, not a total-result cap.
30
33
  - `/agents/{id}/histories`, `/eval/agents/{id}/cases`, `/eval/evaluators` all
31
34
  return everything in one shot today (no pagination).
32
35
  - `order_by` defaults to `"desc"` (most recent first) on endpoints that
@@ -275,7 +278,7 @@ the public CLI.
275
278
  | `GET /api/v2/chats/{id}/messages` | Read persisted Chat V2 conversation parts |
276
279
  | `GET /histories?agent_id=X&feedback_filter=improve_feedback&external_user_id=…` | List conversations with filters |
277
280
  | `GET /histories/{id}` | Read one history's metadata |
278
- | `GET /histories/{id}/conversations` | Full conversation turns incl. tool calls |
281
+ | `GET /histories/{id}/conversations` | Legacy compact conversation rows; not complete tool I/O |
279
282
  | `POST /histories/{hid}/conversations/{cid}/feedbacks` | Leave freeform improvement feedback |
280
283
  | `POST /histories/{hid}/conversations/{cid}/score` | Numeric score |
281
284
 
@@ -467,7 +470,7 @@ For bulk creation, `codeer eval cases-apply --attachments-dir <dir>` reads
467
470
  each case's `attachment_files: ["x.jpg"]` array, uploads, and attaches in one
468
471
  pass. Workspace scope is inferred from the API-key virtual user profile.
469
472
 
470
- ### 11. Tool args + outputs are NOT persisted in history reads
473
+ ### 11. Tool args + outputs are not available in legacy V1 history reads
471
474
 
472
475
  Legacy V1 `History` conversation rows have only three roles
473
476
  (`OpenAIChatRole = system | user | assistant`) — there is no `tool` role row.
@@ -492,8 +495,8 @@ Chat V2 improves this contract: structured SSE emits tool calls and returns as
492
495
  `response.part.created` / `response.part.completed`, and
493
496
  `GET /api/v2/chats/{id}/messages` reads the persisted conversation parts.
494
497
  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.
498
+ paginated V2 message read for persisted after-the-fact tool I/O. The legacy V1
499
+ history read remains useful for compact turn-level compatibility only.
497
500
 
498
501
  ### 10. A KB has exactly ONE level of folders — no nesting
499
502
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codeer-cli
3
- Version: 0.1.11
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,6 +172,7 @@ 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 conversations <history-id> --out .codeer/current/history-<history-id>.json
175
176
  codeer history create --agent <agent-id> --message "Review this plan" --timeout 240
176
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
@@ -196,6 +197,11 @@ Flags:
196
197
  for raw eval results, full conversation turns, full rubric matrices, and
197
198
  other data that can grow with cases, versions, or turns.
198
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
+
199
205
  Avoid piping large raw JSON directly into agent chat. Prefer `--out`, then ask
200
206
  the coding agent to inspect targeted summaries, IDs, failing cases, or selected
201
207
  snippets from the saved file.
@@ -154,6 +154,7 @@ 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 conversations <history-id> --out .codeer/current/history-<history-id>.json
157
158
  codeer history create --agent <agent-id> --message "Review this plan" --timeout 240
158
159
  codeer history send <history-id> --message "Use the recommended options" --timeout 240
159
160
  codeer eval run --agent <agent-id> --cases <case-ids> --evaluator <evaluator-id> --out .codeer/eval_run.json
@@ -178,6 +179,11 @@ Flags:
178
179
  for raw eval results, full conversation turns, full rubric matrices, and
179
180
  other data that can grow with cases, versions, or turns.
180
181
 
182
+ `history conversations` reads Chat V2 parts and follows all pages
183
+ automatically. Its stdout is still a bounded summary; the `--out` artifact is
184
+ the complete client-visible history, including tool calls/results,
185
+ attachments, interactions, feedback, and passthrough metadata.
186
+
181
187
  Avoid piping large raw JSON directly into agent chat. Prefer `--out`, then ask
182
188
  the coding agent to inspect targeted summaries, IDs, failing cases, or selected
183
189
  snippets from the saved file.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codeer-cli"
7
- version = "0.1.11"
7
+ version = "0.1.12"
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"
@@ -186,14 +186,50 @@ def list_messages(
186
186
  external_user_id: Optional[str] = None,
187
187
  limit: int = 500,
188
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
- )
189
+ """Return every client-visible Chat V2 part for a persisted chat.
190
+
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
197
233
 
198
234
 
199
235
  def list_chats(client: CodeerClient) -> list[dict]:
@@ -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
@@ -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
 
@@ -355,7 +379,7 @@ def run_create(args, client) -> int:
355
379
  "messages": message_results,
356
380
  "conversation_parts": conversation_parts,
357
381
  }
358
- write_json(args.out, strip_noisy_fields(out))
382
+ write_json(args.out, out)
359
383
  print_json({
360
384
  "agent_id": agent_id,
361
385
  "history_id": history_id,
@@ -418,7 +442,7 @@ def run_send(args, client) -> int:
418
442
  "messages": message_results,
419
443
  "conversation_parts": conversation_parts,
420
444
  }
421
- write_json(args.out, strip_noisy_fields(out))
445
+ write_json(args.out, out)
422
446
  print_json({
423
447
  "agent_id": agent_id,
424
448
  "history_id": args.history_id,
@@ -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)
@@ -19,6 +19,23 @@ class RecordingClient:
19
19
  return {"chat_id": 123, "messages": []}
20
20
 
21
21
 
22
+ class PaginatedClient(RecordingClient):
23
+ def __init__(self, messages: list[dict]) -> None:
24
+ super().__init__()
25
+ self.messages = messages
26
+
27
+ def get(self, path: str, **kwargs):
28
+ self.calls.append(("GET", path, kwargs))
29
+ params = kwargs["params"]
30
+ offset = params["offset"]
31
+ limit = params["limit"]
32
+ return {
33
+ "chat_id": 123,
34
+ "reply_context_anchor": {"part_id": 99, "sequence": 99},
35
+ "messages": self.messages[offset:offset + limit],
36
+ }
37
+
38
+
22
39
  class ChatV2RequestTests(unittest.TestCase):
23
40
  def test_create_uses_v2_and_always_sends_name(self) -> None:
24
41
  client = RecordingClient()
@@ -59,6 +76,42 @@ class ChatV2RequestTests(unittest.TestCase):
59
76
  },
60
77
  ))
61
78
 
79
+ def test_list_messages_fetches_every_page_without_mutating_parts(self) -> None:
80
+ messages = [
81
+ {
82
+ "id": idx,
83
+ "part_kind": "tool-return",
84
+ "content": {"content": {"owner": f"owner-{idx}"}},
85
+ "metadata": {"agent_history_id": f"version-{idx}"},
86
+ }
87
+ for idx in range(5)
88
+ ]
89
+ client = PaginatedClient(messages)
90
+
91
+ result = chats.list_messages(client, 123, limit=2) # type: ignore[arg-type]
92
+
93
+ self.assertEqual(result["messages"], messages)
94
+ self.assertEqual(result["reply_context_anchor"], {"part_id": 99, "sequence": 99})
95
+ self.assertEqual(
96
+ [call[2]["params"]["offset"] for call in client.calls],
97
+ [0, 2, 4],
98
+ )
99
+
100
+ def test_list_messages_checks_one_empty_page_for_exact_page_multiple(self) -> None:
101
+ client = PaginatedClient([{"id": idx} for idx in range(4)])
102
+
103
+ result = chats.list_messages(client, 123, limit=2) # type: ignore[arg-type]
104
+
105
+ self.assertEqual([message["id"] for message in result["messages"]], [0, 1, 2, 3])
106
+ self.assertEqual(
107
+ [call[2]["params"]["offset"] for call in client.calls],
108
+ [0, 2, 4],
109
+ )
110
+
111
+ def test_list_messages_rejects_non_positive_page_size(self) -> None:
112
+ with self.assertRaisesRegex(ValueError, "limit must be greater than zero"):
113
+ chats.list_messages(RecordingClient(), 123, limit=0) # type: ignore[arg-type]
114
+
62
115
 
63
116
  class ChatV2StreamTests(unittest.TestCase):
64
117
  def test_collects_structured_events_and_final_text(self) -> None:
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import unittest
5
+ from contextlib import redirect_stdout
6
+ from io import StringIO
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+ from types import SimpleNamespace
10
+ from unittest.mock import patch
11
+
12
+ from codeer_cli import histories
13
+ from codeer_cli.commands import history as history_cmd
14
+
15
+
16
+ class FakeClient:
17
+ def resolve_scope(self) -> tuple[str, str]:
18
+ return "workspace-1", "organization-1"
19
+
20
+
21
+ class HistoryReadTests(unittest.TestCase):
22
+ def test_run_conversations_uses_v2_and_writes_unmodified_parts(self) -> None:
23
+ client = FakeClient()
24
+ response = {
25
+ "chat_id": 18649,
26
+ "messages": [
27
+ {
28
+ "id": 10,
29
+ "conversation_group_id": "group-1",
30
+ "sequence": 1,
31
+ "part_kind": "user-prompt",
32
+ "content": {"content": "Question"},
33
+ "metadata": {"agent_history_id": "version-1"},
34
+ "source": "stack",
35
+ "attached_files": [],
36
+ "feedbacks": [],
37
+ },
38
+ {
39
+ "id": 11,
40
+ "conversation_group_id": "group-1",
41
+ "sequence": 2,
42
+ "part_kind": "tool-return",
43
+ "content": {"content": {"owner": "Ada", "members": ["Grace"]}},
44
+ "metadata": {"reasoning_step_type": "consultant_http_request"},
45
+ "source": "stack",
46
+ "attached_files": [],
47
+ "feedbacks": [],
48
+ },
49
+ ],
50
+ }
51
+ args = SimpleNamespace(history_id=18649, full=False, out=None)
52
+
53
+ with TemporaryDirectory() as tmpdir:
54
+ out_path = Path(tmpdir) / "history.json"
55
+ args.out = str(out_path)
56
+ stdout = StringIO()
57
+ with (
58
+ patch.object(history_cmd.chats_mod, "list_messages", return_value=response) as list_messages,
59
+ redirect_stdout(stdout),
60
+ ):
61
+ result = history_cmd.run_conversations(args, client)
62
+
63
+ artifact = json.loads(out_path.read_text())
64
+
65
+ self.assertEqual(result, 0)
66
+ list_messages.assert_called_once_with(client, 18649)
67
+ self.assertEqual(artifact, response)
68
+ self.assertEqual(artifact["messages"][1]["content"]["content"]["owner"], "Ada")
69
+ summary = json.loads(stdout.getvalue())
70
+ self.assertEqual(summary["turn_count"], 1)
71
+ self.assertEqual(summary["part_count"], 2)
72
+ self.assertTrue(summary["stdout_is_summary"])
73
+ self.assertEqual(summary["parts"][1]["part_kind"], "tool-return")
74
+
75
+ def test_negative_feedback_uses_v2_part_feedback_and_grouped_user_prompt(self) -> None:
76
+ client = FakeClient()
77
+ history_rows = [{
78
+ "id": 18649,
79
+ "name": "Support chat",
80
+ "external_user_id": "user-1",
81
+ "created_at": "2026-08-10T00:00:00Z",
82
+ }]
83
+ parts = {
84
+ "chat_id": 18649,
85
+ "messages": [
86
+ {
87
+ "id": 1,
88
+ "conversation_group_id": "group-1",
89
+ "part_kind": "user-prompt",
90
+ "content": {"content": "Why did this fail?"},
91
+ },
92
+ {
93
+ "id": 2,
94
+ "conversation_group_id": "group-1",
95
+ "part_kind": "tool-call",
96
+ "content": {"tool_name": "http_request", "args": {"path": "/status"}},
97
+ },
98
+ {
99
+ "id": 3,
100
+ "conversation_group_id": "group-1",
101
+ "part_kind": "text",
102
+ "content": {"content": "The service is healthy."},
103
+ "feedbacks": [{"type": "sys_improve", "content": "Incorrect"}],
104
+ },
105
+ ],
106
+ }
107
+
108
+ with (
109
+ patch.object(histories, "list", return_value=history_rows),
110
+ patch("codeer_cli.chats.list_messages", return_value=parts) as list_messages,
111
+ ):
112
+ rows = histories.list_negative_feedback_turns(client, agent_id="agent-1")
113
+
114
+ list_messages.assert_called_once_with(client, 18649)
115
+ self.assertEqual(rows, [{
116
+ "history_id": 18649,
117
+ "history_title": "Support chat",
118
+ "external_user_id": "user-1",
119
+ "created_at": "2026-08-10T00:00:00Z",
120
+ "turn_idx": 0,
121
+ "part_idx": 2,
122
+ "conversation_group_id": "group-1",
123
+ "conversation_part_id": 3,
124
+ "feedback_type": "sys_improve",
125
+ "feedback_text": "Incorrect",
126
+ "user_message": "Why did this fail?",
127
+ "assistant_excerpt": "The service is healthy.",
128
+ }])
129
+
130
+
131
+ if __name__ == "__main__":
132
+ unittest.main()
@@ -26,7 +26,7 @@ wheels = [
26
26
 
27
27
  [[package]]
28
28
  name = "codeer-cli"
29
- version = "0.1.11"
29
+ version = "0.1.12"
30
30
  source = { editable = "." }
31
31
  dependencies = [
32
32
  { name = "httpx" },
File without changes