semantic-code-review 0.24.2__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.
Files changed (100) hide show
  1. semantic_code_review/__init__.py +5 -0
  2. semantic_code_review/augment/__init__.py +3 -0
  3. semantic_code_review/augment/agents.py +95 -0
  4. semantic_code_review/augment/console.py +440 -0
  5. semantic_code_review/augment/extra_review.py +237 -0
  6. semantic_code_review/augment/fold_summary.py +447 -0
  7. semantic_code_review/augment/hunks.py +264 -0
  8. semantic_code_review/augment/mcp_server.py +156 -0
  9. semantic_code_review/augment/overview.py +233 -0
  10. semantic_code_review/augment/pass_.py +171 -0
  11. semantic_code_review/augment/pipeline.py +571 -0
  12. semantic_code_review/augment/progress.py +223 -0
  13. semantic_code_review/augment/prompts.py +93 -0
  14. semantic_code_review/augment/schemas.py +408 -0
  15. semantic_code_review/augment/tools.py +475 -0
  16. semantic_code_review/augment/trace_adapter.py +369 -0
  17. semantic_code_review/backends/__init__.py +95 -0
  18. semantic_code_review/backends/_cli_driver.py +571 -0
  19. semantic_code_review/backends/anthropic_sdk.py +56 -0
  20. semantic_code_review/backends/base.py +104 -0
  21. semantic_code_review/backends/claude_cli.py +421 -0
  22. semantic_code_review/backends/google_sdk.py +73 -0
  23. semantic_code_review/backends/openai_compat.py +42 -0
  24. semantic_code_review/cache/__init__.py +3 -0
  25. semantic_code_review/cache/store.py +87 -0
  26. semantic_code_review/cli/__init__.py +70 -0
  27. semantic_code_review/cli/_shared.py +157 -0
  28. semantic_code_review/cli/augment.py +74 -0
  29. semantic_code_review/cli/config_cmd.py +266 -0
  30. semantic_code_review/cli/fetch.py +33 -0
  31. semantic_code_review/cli/lint.py +27 -0
  32. semantic_code_review/cli/pr.py +98 -0
  33. semantic_code_review/cli/review.py +104 -0
  34. semantic_code_review/cli/runs_cmd.py +17 -0
  35. semantic_code_review/cli/show.py +19 -0
  36. semantic_code_review/cli/strip.py +20 -0
  37. semantic_code_review/config.py +563 -0
  38. semantic_code_review/config_template.py +154 -0
  39. semantic_code_review/fetch/__init__.py +61 -0
  40. semantic_code_review/fetch/anchor.py +244 -0
  41. semantic_code_review/fetch/github.py +229 -0
  42. semantic_code_review/fetch/github_comments.py +404 -0
  43. semantic_code_review/fetch/local.py +443 -0
  44. semantic_code_review/fetch/run_source.py +70 -0
  45. semantic_code_review/format/__init__.py +3 -0
  46. semantic_code_review/format/emit.py +163 -0
  47. semantic_code_review/format/lint.py +74 -0
  48. semantic_code_review/format/parse.py +574 -0
  49. semantic_code_review/format/sidecar.py +19 -0
  50. semantic_code_review/format/strip.py +20 -0
  51. semantic_code_review/git_ops.py +252 -0
  52. semantic_code_review/paths.py +61 -0
  53. semantic_code_review/review/__init__.py +3 -0
  54. semantic_code_review/review/comments.py +176 -0
  55. semantic_code_review/review/github.py +284 -0
  56. semantic_code_review/review/github_graphql.py +433 -0
  57. semantic_code_review/review/pr_flow.py +299 -0
  58. semantic_code_review/review/runner.py +407 -0
  59. semantic_code_review/review/server.py +1043 -0
  60. semantic_code_review/structural/__init__.py +41 -0
  61. semantic_code_review/structural/diff.py +102 -0
  62. semantic_code_review/structural/parse.py +330 -0
  63. semantic_code_review/structural/symbols.py +43 -0
  64. semantic_code_review/viewer/__init__.py +3 -0
  65. semantic_code_review/viewer/assets/annotations.ts +547 -0
  66. semantic_code_review/viewer/assets/boot.ts +273 -0
  67. semantic_code_review/viewer/assets/comment_store.ts +121 -0
  68. semantic_code_review/viewer/assets/comments.ts +624 -0
  69. semantic_code_review/viewer/assets/console.ts +426 -0
  70. semantic_code_review/viewer/assets/console_render.ts +213 -0
  71. semantic_code_review/viewer/assets/console_selection.ts +102 -0
  72. semantic_code_review/viewer/assets/data_store.ts +156 -0
  73. semantic_code_review/viewer/assets/file_rows.ts +49 -0
  74. semantic_code_review/viewer/assets/folds.ts +560 -0
  75. semantic_code_review/viewer/assets/index.html +62 -0
  76. semantic_code_review/viewer/assets/post_modal.ts +383 -0
  77. semantic_code_review/viewer/assets/progress.ts +138 -0
  78. semantic_code_review/viewer/assets/render.ts +937 -0
  79. semantic_code_review/viewer/assets/sidebar.ts +429 -0
  80. semantic_code_review/viewer/assets/sse.ts +78 -0
  81. semantic_code_review/viewer/assets/text_highlight.ts +215 -0
  82. semantic_code_review/viewer/assets/types.d.ts +388 -0
  83. semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
  84. semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
  85. semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
  86. semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
  87. semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
  88. semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
  89. semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
  90. semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
  91. semantic_code_review/viewer/assets/viewer.css +1202 -0
  92. semantic_code_review/viewer/assets/viewer.js +10588 -0
  93. semantic_code_review/viewer/build_json.py +546 -0
  94. semantic_code_review/viewer/hunk_layout.py +471 -0
  95. semantic_code_review-0.24.2.dist-info/METADATA +348 -0
  96. semantic_code_review-0.24.2.dist-info/RECORD +100 -0
  97. semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
  98. semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
  99. semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
  100. semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """Semantic Code Review: LLM-augmented PR diff viewer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.23.0"
@@ -0,0 +1,3 @@
1
+ """LLM augmentation pipeline."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,95 @@
1
+ """Pydantic-ai Agent factories for both SDK and CLI backends.
2
+
3
+ Two factories — one per pass — that wire the right `output_type`,
4
+ instructions, and tool set. The Agent itself is stateless across runs;
5
+ the per-run `RepoTools` is supplied via `deps=` to `Agent.run`.
6
+
7
+ `model` accepts either a fully-qualified pydantic-ai model id string
8
+ (e.g. `anthropic:claude-opus-4-7` or `google-vertex:gemini-2.5-pro`)
9
+ *or* a `pydantic_ai.models.Model` instance — the CLI driver in
10
+ `backends/claude_cli.py` is a Model subclass that wraps the
11
+ `claude -p` client.
12
+ `cli._select_client` is the single place that decides which form an
13
+ unqualified model name maps to.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ from pydantic_ai import Agent
21
+ from pydantic_ai.models import Model
22
+ from pydantic_ai.output import ToolOutput
23
+
24
+ from .prompts import HUNK_SYSTEM, OVERVIEW_SYSTEM
25
+ from .schemas import HunkAnnotations, OverviewSubmission
26
+ from .tools import TOOL_FUNCTIONS, RepoTools
27
+
28
+
29
+ def make_overview_agent(model: str | Model) -> Agent[None, OverviewSubmission]:
30
+ """Agent for the PR-level overview pass.
31
+
32
+ No repo tools are registered — the overview pass works purely from
33
+ the diffstat + hunk headers in its prompt. Output is constrained
34
+ via `ToolOutput(OverviewSubmission, name='submit_overview')`.
35
+ """
36
+ return Agent(
37
+ model=model,
38
+ output_type=ToolOutput(OverviewSubmission, name="submit_overview"),
39
+ instructions=OVERVIEW_SYSTEM,
40
+ )
41
+
42
+
43
+ def make_hunk_agent(model: str | Model) -> Agent[RepoTools, HunkAnnotations]:
44
+ """Agent for the per-hunk annotation pass.
45
+
46
+ Registers the repo tool functions so the SDK Agent can `read_file`,
47
+ `grep`, etc. against the run's worktree. CLI backends ignore
48
+ `function_tools` — they expose the same tools to the underlying
49
+ subprocess via the MCP server. Output is constrained via
50
+ `ToolOutput(HunkAnnotations, name='submit_annotations')`.
51
+ """
52
+ return Agent(
53
+ model=model,
54
+ deps_type=RepoTools,
55
+ output_type=ToolOutput(HunkAnnotations, name="submit_annotations"),
56
+ instructions=HUNK_SYSTEM,
57
+ tools=TOOL_FUNCTIONS,
58
+ )
59
+
60
+
61
+ @dataclass
62
+ class Client:
63
+ """Pipeline-side handle for an LLM backend.
64
+
65
+ Holds either a pydantic-ai model id string (for SDK backends) or
66
+ a `pydantic_ai.models.Model` instance (for CLI subprocess backends —
67
+ the CLI drivers under `backends/`). The pipeline calls
68
+ `make_*_agent(client.model)` to build pass-specific agents.
69
+
70
+ `set_repo_tools` proxies to the inner CLI Model when present so the
71
+ subprocess can spawn an MCP server bound to the run's worktree;
72
+ SDK string models have no repo-tool concept here — the SDK Agent
73
+ receives `deps=repo_tools` at `Agent.run` call time. The pipeline
74
+ calls both: `client.set_repo_tools(rt)` for the CLI side, and
75
+ passes `rt` as `deps=` for the SDK side.
76
+
77
+ `aclose()` is delegated to the inner Model. SDK string models have
78
+ no per-run resources to release; the no-op fallthrough is fine.
79
+ """
80
+
81
+ model: str | Model
82
+ is_subprocess_backend: bool = False
83
+
84
+ def set_repo_tools(self, repo_tools: RepoTools | None) -> None:
85
+ if isinstance(self.model, Model):
86
+ setter = getattr(self.model, "set_repo_tools", None)
87
+ if callable(setter):
88
+ setter(repo_tools)
89
+
90
+ async def aclose(self) -> None:
91
+ if isinstance(self.model, Model):
92
+ close = getattr(self.model, "aclose", None)
93
+ if callable(close):
94
+ # Dynamic probe: getattr erases the type, so pyright can't see the coroutine.
95
+ await close() # pyright: ignore[reportGeneralTypeIssues]
@@ -0,0 +1,440 @@
1
+ """Free-form review console agent.
2
+
3
+ The console is the first agent in the codebase without a `ToolOutput`
4
+ submit tool: it emits prose and calls the same read-only `RepoTools`
5
+ the augment passes use, plus a console-only `hunk(id)` diff accessor.
6
+ It is wired into the live `scr review` server after augmentation
7
+ completes, for both SDK and CLI subprocess backends (ADR 0002 —
8
+ console).
9
+
10
+ This module owns the agent factory, the compact first-turn seed, and
11
+ the turn drivers. The streaming driver (`stream_console_turn`, Slice 2)
12
+ drives `Agent.iter` and pumps text deltas + tool-activity out through
13
+ caller-supplied callbacks while polling a cancel flag between chunks.
14
+ CLI subprocess backends (Slice 5) can't stream, so `stream_console_turn`
15
+ detects them and falls back to a one-shot `Agent.run`
16
+ (`_run_console_turn_oneshot`): one `console-done` with the whole answer,
17
+ no intermediate deltas. `run_console_turn` is the Slice 1 blocking
18
+ shape, retained as a thin no-callback wrapper.
19
+
20
+ Context discipline (ADR 0002): **seed compact, pull on demand.** The
21
+ first turn carries the overview JSON + changed-file list + the
22
+ deterministic `SymbolDelta` (all bounded); bulk content comes through
23
+ tools, including `hunk(id)`. The seed rides the conversation's
24
+ `message_history`, so it is paid once, not re-injected per turn.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import logging
31
+ import threading
32
+ from collections.abc import Callable
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from pydantic_ai import Agent
37
+ from pydantic_ai.messages import (
38
+ FunctionToolCallEvent,
39
+ PartDeltaEvent,
40
+ PartStartEvent,
41
+ TextPart,
42
+ TextPartDelta,
43
+ )
44
+ from pydantic_ai.models import Model
45
+
46
+ from .agents import Client
47
+ from .hunks import overview_to_prompt_json
48
+ from .tools import RepoTools, console_tool_functions
49
+
50
+ log = logging.getLogger(__name__)
51
+
52
+
53
+ CONSOLE_SYSTEM = (
54
+ "You are a code-review console embedded in a live diff viewer. The "
55
+ "reviewer is reading one change (a PR or a local diff) and asks you "
56
+ "free-form questions about it: what calls this, why a guard exists, "
57
+ "what a refactor moved, whether an edge case is handled.\n\n"
58
+ "You have read-only tools over the change's base and head worktrees:\n"
59
+ " - read_file / read_file_at — file contents at head or any SHA\n"
60
+ " - grep — search the head worktree\n"
61
+ " - outline / symbol_at — deterministic tree-sitter symbol structure\n"
62
+ " - changed_symbols — the structural base->head delta\n"
63
+ " - list_dir / git_log — directory + history\n"
64
+ " - hunk(id) — the exact diff text of a hunk, by its 'H<file>_<hunk>' id\n\n"
65
+ "Ground every answer in the code. Prefer calling a tool over guessing; "
66
+ "when you state a fact about the code, cite it as `path:line` so the "
67
+ "reviewer can jump to it. The first message seeds you with the PR "
68
+ "overview, the changed-file list, and the deterministic symbol delta — "
69
+ "reach for tools for anything beyond that.\n\n"
70
+ "Be concise and direct. Answer the question asked; don't pad with "
71
+ "restatements or caveats. If the code doesn't settle the question, say "
72
+ "what you'd need to look at rather than speculating.\n\n"
73
+ "The reviewer may pin a selection to a question — highlighted code "
74
+ "(with its enclosing hunk inlined) or a comment. When a turn carries "
75
+ "a '# Reviewer selection' block, treat it as the subject of 'this' in "
76
+ "the question and ground your answer in it.\n\n"
77
+ "Your answers render as GitHub-flavoured markdown: use code spans for "
78
+ "identifiers and `path:line` citations, fenced code blocks (with a "
79
+ "language) for snippets, and lists or short headings to structure a "
80
+ "longer answer. Don't emit raw HTML. When — and only when — a diagram "
81
+ "genuinely clarifies the answer (a control- or data-flow, a call graph, "
82
+ "a state machine, a sequence of calls across files), emit it as a "
83
+ "```mermaid``` fenced block; for a plain factual answer, prose is "
84
+ "better than a diagram. Keep diagrams small and label nodes with the "
85
+ "real symbol names."
86
+ )
87
+
88
+
89
+ class ConsoleNotReady(RuntimeError):
90
+ """The run dir doesn't yet hold an `augmented.scr.json`.
91
+
92
+ Maps to HTTP 409 at the review-server boundary — augmentation is
93
+ still in flight or was skipped, so there's no diff to ground the
94
+ console against.
95
+ """
96
+
97
+
98
+ class ConsoleCancelled(RuntimeError):
99
+ """The reviewer cancelled an in-flight turn (Stop / Esc).
100
+
101
+ Raised by `stream_console_turn` when the cancel flag trips between
102
+ chunks. The background worker catches it and emits a `console-done`
103
+ with `cancelled: true`; the partial turn is discarded (its messages
104
+ never join the conversation history).
105
+ """
106
+
107
+
108
+ def make_console_agent(model: str | Model) -> Agent[RepoTools, str]:
109
+ """Free-form console agent: prose output (no `output_type`), the
110
+ `RepoTools` surface plus `hunk(id)`, and the `CONSOLE_SYSTEM`
111
+ persona. Stateless across turns; the per-turn `RepoTools` is passed
112
+ as `deps=` and the conversation rides `message_history`.
113
+ """
114
+ return Agent(
115
+ model=model,
116
+ deps_type=RepoTools,
117
+ instructions=CONSOLE_SYSTEM,
118
+ tools=console_tool_functions(),
119
+ )
120
+
121
+
122
+ def build_console_seed(diff: Any, *, symbol_delta_json: str | None) -> str:
123
+ """Build the compact first-turn seed string.
124
+
125
+ Carries the overview JSON, a one-line-per-file changed-file list,
126
+ and the deterministic `SymbolDelta` JSON when available. Everything
127
+ here is already computed and bounded; bulk content is left to tools.
128
+ """
129
+ files_lines: list[str] = []
130
+ for fp in diff.files:
131
+ role = fp.ann.role.value if getattr(fp.ann, "role", None) else "modified"
132
+ summary = (getattr(fp.ann, "summary", "") or "").strip().replace("\n", " ")
133
+ line = f"- {fp.path} ({role})"
134
+ if summary:
135
+ line += f" — {summary}"
136
+ files_lines.append(line)
137
+ files_block = "\n".join(files_lines) or "(no files)"
138
+
139
+ parts = [
140
+ f"# PR overview\n{overview_to_prompt_json(diff)}",
141
+ f"# Changed files\n{files_block}",
142
+ ]
143
+ if symbol_delta_json:
144
+ parts.append(f"# Structural symbol delta (deterministic tree-sitter base->head)\n{symbol_delta_json}")
145
+ return "\n\n".join(parts)
146
+
147
+
148
+ #: Defensive cap on the reviewer-supplied selection text folded into a
149
+ #: turn. The browser only ever sends what's visibly selected, but the
150
+ #: payload is untrusted, so bound it rather than trust the client.
151
+ _SELECTION_CAP = 4000
152
+
153
+
154
+ def _format_selection(selection: Any, repo_tools: RepoTools) -> str:
155
+ """Render a reviewer's pinned selection as a prompt block, or ``""``.
156
+
157
+ The block names what was highlighted and quotes it; for a code
158
+ selection with a resolvable hunk id it also inlines the enclosing
159
+ hunk via the :meth:`RepoTools.hunk` accessor so the model sees the
160
+ selection in its diff context. Comment/plain selections carry just
161
+ the quoted text. An absent/empty/non-dict selection yields ``""``.
162
+
163
+ Wire shape (from the viewer's `console_selection.ts`):
164
+ ``{selection_text, selection_kind, file?, side?, hunk_id?,
165
+ line_range?}``.
166
+ """
167
+ if not isinstance(selection, dict):
168
+ return ""
169
+ text = str(selection.get("selection_text") or "").strip()
170
+ if not text:
171
+ return ""
172
+ if len(text) > _SELECTION_CAP:
173
+ text = text[:_SELECTION_CAP] + "\n…(truncated)"
174
+ kind = str(selection.get("selection_kind") or "plain")
175
+
176
+ where = ""
177
+ file = selection.get("file")
178
+ if kind == "code" and file:
179
+ where = f" in `{file}`"
180
+ rng = selection.get("line_range")
181
+ side = selection.get("side")
182
+ if isinstance(rng, (list, tuple)) and len(rng) == 2:
183
+ lo, hi = rng
184
+ span = f"line {lo}" if lo == hi else f"lines {lo}–{hi}"
185
+ side_txt = f", {side} side" if side in ("old", "new") else ""
186
+ where += f" ({span}{side_txt})"
187
+
188
+ parts = [
189
+ f"# Reviewer selection ({kind}){where}",
190
+ "The reviewer highlighted this and is asking about it:",
191
+ f"```\n{text}\n```",
192
+ ]
193
+ if kind == "code":
194
+ hunk_id = selection.get("hunk_id")
195
+ if hunk_id:
196
+ hunk_text = repo_tools.hunk(str(hunk_id))
197
+ # A bad/absent hunk id degrades to text-only — never surface
198
+ # the accessor's error string into the prompt.
199
+ if not hunk_text.startswith("error:"):
200
+ parts += ["Enclosing hunk:", f"```diff\n{hunk_text}\n```"]
201
+ return "\n".join(parts)
202
+
203
+
204
+ def _prepare_turn(
205
+ client: Client,
206
+ *,
207
+ run_dir: Path,
208
+ question: str,
209
+ history: list | None,
210
+ selection: Any = None,
211
+ ) -> tuple[Agent[RepoTools, str], str, RepoTools]:
212
+ """Shared per-turn setup: load the sidecar, bind `RepoTools`, build
213
+ the agent, and assemble the prompt (the compact seed prefix on the
214
+ first turn, the bare question thereafter).
215
+
216
+ When ``selection`` is present it is folded once into this turn's user
217
+ message, just ahead of the question — turn-anchored, so it rides the
218
+ conversation history and is never re-injected on later turns.
219
+
220
+ Raises :class:`ConsoleNotReady` if the augmented sidecar isn't on
221
+ disk yet. Returns ``(agent, prompt, repo_tools)`` ready to run or
222
+ iterate.
223
+ """
224
+ sidecar = run_dir / "augmented.scr.json"
225
+ if not sidecar.exists():
226
+ raise ConsoleNotReady("augmented.scr.json missing — augment not complete")
227
+
228
+ # Lazy: keep the format machinery off the import path for the
229
+ # agent-factory-only callers.
230
+ from ..format.sidecar import load_sidecar
231
+
232
+ diff = load_sidecar(sidecar)
233
+ repo_tools = RepoTools(
234
+ head_worktree=run_dir / "head",
235
+ repo_git=run_dir / "repo.git",
236
+ base_sha=diff.pr.base_sha,
237
+ head_sha=diff.pr.head_sha,
238
+ diff=diff,
239
+ )
240
+
241
+ selection_block = _format_selection(selection, repo_tools)
242
+ question_section = f"# Reviewer question\n{question}"
243
+ if selection_block:
244
+ question_section = f"{selection_block}\n\n{question_section}"
245
+
246
+ if history:
247
+ prompt = question_section
248
+ else:
249
+ # Best-effort structural seed: a parse failure leaves the seed
250
+ # without the delta rather than failing the turn.
251
+ symbol_delta_json: str | None = None
252
+ try:
253
+ symbol_delta_json = repo_tools.compute_symbol_delta().model_dump_json()
254
+ except Exception: # noqa: BLE001 — seed is best-effort
255
+ log.warning("console seed: symbol delta failed", exc_info=True)
256
+ seed = build_console_seed(diff, symbol_delta_json=symbol_delta_json)
257
+ prompt = f"{seed}\n\n{question_section}"
258
+
259
+ return make_console_agent(client.model), prompt, repo_tools
260
+
261
+
262
+ def _delta_text(event: Any) -> str:
263
+ """Pull streamed assistant text out of a pydantic-ai stream event.
264
+
265
+ A `TextPart` opens with its first chunk in `PartStartEvent`; the
266
+ rest arrive as `TextPartDelta`s. Everything else (tool-call parts,
267
+ thinking deltas) yields the empty string and is skipped by callers.
268
+ """
269
+ if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
270
+ return event.part.content
271
+ if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
272
+ return event.delta.content_delta
273
+ return ""
274
+
275
+
276
+ def _tool_label(part: Any) -> str:
277
+ """A compact, human-readable label for a tool call — e.g.
278
+ ``grep RepoTools`` or ``read_file src/users.py``.
279
+
280
+ Surfaces the tool name plus a representative scalar argument so the
281
+ reviewer sees *what* the console is reaching for, not just *that* it
282
+ is. Args arrive as a dict or a JSON string depending on the backend.
283
+ """
284
+ name = getattr(part, "tool_name", None) or "tool"
285
+ args = getattr(part, "args", None)
286
+ if isinstance(args, str):
287
+ try:
288
+ args = json.loads(args)
289
+ except ValueError:
290
+ args = None
291
+ if isinstance(args, dict):
292
+ for value in args.values():
293
+ text = str(value).strip()
294
+ if text:
295
+ return f"{name} {text[:60]}"
296
+ return name
297
+
298
+
299
+ async def _run_console_turn_oneshot(
300
+ client: Client,
301
+ agent: Agent[RepoTools, str],
302
+ prompt: str,
303
+ repo_tools: RepoTools,
304
+ *,
305
+ history: list | None,
306
+ cancel: threading.Event | None,
307
+ ) -> tuple[str, list]:
308
+ """Run one console turn to completion via `Agent.run` (CLI backends).
309
+
310
+ The CLI driver exposes the worktree to the subprocess through MCP,
311
+ not pydantic-ai `deps`, so `RepoTools` is bound onto the client's
312
+ inner Model for the duration of the call (and unbound after, which
313
+ also cleans up the temp MCP-config file). `deps=` is still passed so
314
+ the agent's tool surface validates. Cancel is best-effort: the
315
+ subprocess runs as one opaque shot, so we honour the flag before and
316
+ after rather than mid-flight.
317
+ """
318
+ if cancel is not None and cancel.is_set():
319
+ raise ConsoleCancelled("console turn cancelled")
320
+ client.set_repo_tools(repo_tools)
321
+ try:
322
+ result = await agent.run(prompt, deps=repo_tools, message_history=history)
323
+ finally:
324
+ client.set_repo_tools(None)
325
+ if cancel is not None and cancel.is_set():
326
+ raise ConsoleCancelled("console turn cancelled")
327
+ return result.output, list(result.all_messages())
328
+
329
+
330
+ async def stream_console_turn(
331
+ client: Client,
332
+ *,
333
+ run_dir: Path,
334
+ question: str,
335
+ history: list | None = None,
336
+ on_delta: Callable[[str], None] | None = None,
337
+ on_tool: Callable[[str], None] | None = None,
338
+ cancel: threading.Event | None = None,
339
+ selection: Any = None,
340
+ ) -> tuple[str, list]:
341
+ """Stream one console turn, returning ``(answer, new_history)``.
342
+
343
+ Drives the agent via ``Agent.iter`` so assistant text can be pumped
344
+ to ``on_delta`` chunk-by-chunk and each tool invocation announced to
345
+ ``on_tool`` as it fires. Between chunks it polls ``cancel`` (a
346
+ ``threading.Event`` flipped from another thread by ``/console/cancel``)
347
+ and raises :class:`ConsoleCancelled` when set — the partial turn is
348
+ abandoned and its messages never join the returned history.
349
+
350
+ `history` is the opaque prior `message_history` (None on the first
351
+ turn); the returned list is the full history to carry forward.
352
+ `selection`, when present, is the reviewer's pinned selection, folded
353
+ once into this turn's user message (see :func:`_format_selection`).
354
+ """
355
+ agent, prompt, repo_tools = _prepare_turn(
356
+ client,
357
+ run_dir=run_dir,
358
+ question=question,
359
+ history=history,
360
+ selection=selection,
361
+ )
362
+
363
+ # CLI backends (Slice 5) can't stream: the subprocess runs its own
364
+ # opaque tool loop and returns the whole answer at once, and
365
+ # `Agent.iter`'s per-node streaming would hit the driver's
366
+ # unimplemented `request_stream`. Run one-shot via `Agent.run`
367
+ # instead — the worker emits a single `console-done` with the full
368
+ # text, which the frontend renders identically to "zero deltas, one
369
+ # done". `on_delta`/`on_tool` simply never fire.
370
+ if client.is_subprocess_backend:
371
+ return await _run_console_turn_oneshot(
372
+ client,
373
+ agent,
374
+ prompt,
375
+ repo_tools,
376
+ history=history,
377
+ cancel=cancel,
378
+ )
379
+
380
+ def cancelled() -> bool:
381
+ return cancel is not None and cancel.is_set()
382
+
383
+ abort = False
384
+ async with agent.iter(
385
+ prompt,
386
+ deps=repo_tools,
387
+ message_history=history,
388
+ ) as run:
389
+ async for node in run:
390
+ if cancelled():
391
+ abort = True
392
+ break
393
+ if Agent.is_model_request_node(node):
394
+ async with node.stream(run.ctx) as stream:
395
+ async for event in stream:
396
+ if cancelled():
397
+ abort = True
398
+ break
399
+ chunk = _delta_text(event)
400
+ if chunk and on_delta is not None:
401
+ on_delta(chunk)
402
+ if abort:
403
+ break
404
+ elif Agent.is_call_tools_node(node):
405
+ async with node.stream(run.ctx) as stream:
406
+ async for event in stream:
407
+ if isinstance(event, FunctionToolCallEvent) and on_tool is not None:
408
+ on_tool(_tool_label(event.part))
409
+
410
+ if abort:
411
+ raise ConsoleCancelled("console turn cancelled")
412
+ result = run.result
413
+ # Non-None once the iteration runs to completion (only the cancel
414
+ # path leaves it unset, and that raised above).
415
+ assert result is not None, "agent run finished without a result"
416
+ return result.output, list(result.all_messages())
417
+
418
+
419
+ async def run_console_turn(
420
+ client: Client,
421
+ *,
422
+ run_dir: Path,
423
+ question: str,
424
+ history: list | None = None,
425
+ selection: Any = None,
426
+ ) -> tuple[str, list]:
427
+ """Run one console turn to completion and return (answer, new_history).
428
+
429
+ The Slice 1 blocking shape, retained for the non-streaming callers
430
+ and tests: a thin wrapper over :func:`stream_console_turn` with no
431
+ callbacks and no cancel flag, so it accumulates the full answer and
432
+ returns it once the agent finishes.
433
+ """
434
+ return await stream_console_turn(
435
+ client,
436
+ run_dir=run_dir,
437
+ question=question,
438
+ history=history,
439
+ selection=selection,
440
+ )