Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/clayde/prompts/address_review.j2
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Steps:
After making all changes, provide a short summary of what you changed in response
to each review comment.

Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping.
IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else.
Do not include any text before or after the JSON. Do not wrap it in markdown code fences.
Your entire response must be parseable by json.loads().

{"summary": "<short summary of changes made in response to review comments>"}
4 changes: 3 additions & 1 deletion src/clayde/prompts/implement.j2
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Steps:

Do NOT create a pull request — it will be created automatically after you finish.

Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping.
IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else.
Do not include any text before or after the JSON. Do not wrap it in markdown code fences.
Your entire response must be parseable by json.loads().

{"summary": "<short summary of what was implemented>"}
4 changes: 3 additions & 1 deletion src/clayde/prompts/preliminary_plan.j2
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Also determine:
`clayde/issue-{{ number }}-<slug>` where `<slug>` is a 1-to-3 word lowercase
hyphenated description (e.g. `clayde/issue-{{ number }}-better-branch-naming`).

Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping.
IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else.
Do not include any text before or after the JSON. Do not wrap it in markdown code fences.
Your entire response must be parseable by json.loads().

{"plan": "<preliminary plan text in markdown>", "size": "small|large", "branch_name": "clayde/issue-{{ number }}-<slug>"}
4 changes: 3 additions & 1 deletion src/clayde/prompts/thorough_plan.j2
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ implementation.
If anything is ambiguous or you need clarification, include your questions
at the end under a "Questions" section.

Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping.
IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else.
Do not include any text before or after the JSON. Do not wrap it in markdown code fences.
Your entire response must be parseable by json.loads().

{"plan": "<implementation plan in markdown>"}
4 changes: 3 additions & 1 deletion src/clayde/prompts/update_plan.j2
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Also produce the complete updated plan (copy unchanged sections verbatim —
do not rephrase, reformat, or restructure parts of the plan that are not
affected by the new comments. Only modify what is necessary).

Output ONLY a JSON object with the following structure. No preamble, no markdown wrapping.
IMPORTANT: Your final response must be ONLY a raw JSON object — nothing else.
Do not include any text before or after the JSON. Do not wrap it in markdown code fences.
Your entire response must be parseable by json.loads().

{"summary": "<short summary of what changed and responses to questions>", "updated_plan": "<complete updated plan in markdown>"}
41 changes: 36 additions & 5 deletions src/clayde/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,44 @@ class AddressReviewResponse(BaseModel):
summary: str


def _strip_code_fences(text: str) -> str:
"""Strip markdown code fences (```json ... ``` or ``` ... ```) from text."""
def _extract_json(text: str) -> str:
"""Extract a JSON object from LLM output that may contain surrounding text.

Tries in order:
1. Code-fenced JSON block (```json ... ``` or ``` ... ```)
2. First top-level { ... } object in the text
3. Falls back to stripped original text
"""
text = text.strip()
# Remove ```json ... ``` or ``` ... ``` fences
m = re.match(r"^```(?:json)?\s*\n([\s\S]*?)\n```$", text)
# 1. Extract from markdown code fence anywhere in the text
m = re.search(r"```(?:json)?\s*\n([\s\S]*?)\n```", text)
if m:
return m.group(1).strip()
# 2. Find the first top-level JSON object by matching braces
start = text.find("{")
if start != -1:
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if escape:
escape = False
continue
if c == "\\":
escape = True
continue
if c == '"' and not escape:
in_string = not in_string
continue
if in_string:
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return text


Expand All @@ -49,7 +80,7 @@ def parse_response(text: str, model_class: type[BaseModel]) -> BaseModel:
Raises:
ValueError: If the text cannot be parsed as JSON or fails validation.
"""
cleaned = _strip_code_fences(text)
cleaned = _extract_json(text)
try:
data = json.loads(cleaned)
except json.JSONDecodeError as e:
Expand Down
35 changes: 27 additions & 8 deletions tests/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,54 @@
PreliminaryPlanResponse,
ThoroughPlanResponse,
UpdatePlanResponse,
_strip_code_fences,
_extract_json,
parse_response,
)


class TestStripCodeFences:
class TestExtractJson:
def test_strips_json_fences(self):
text = "```json\n{\"plan\": \"hello\"}\n```"
assert _strip_code_fences(text) == '{"plan": "hello"}'
assert _extract_json(text) == '{"plan": "hello"}'

def test_strips_plain_fences(self):
text = "```\n{\"plan\": \"hello\"}\n```"
assert _strip_code_fences(text) == '{"plan": "hello"}'
assert _extract_json(text) == '{"plan": "hello"}'

def test_no_fences_unchanged(self):
text = '{"plan": "hello"}'
assert _strip_code_fences(text) == text
assert _extract_json(text) == text

def test_strips_surrounding_whitespace(self):
text = ' {"plan": "hello"} '
assert _strip_code_fences(text) == '{"plan": "hello"}'
assert _extract_json(text) == '{"plan": "hello"}'

def test_partial_fence_unchanged(self):
# Only opening fence — should not strip
text = "```json\n{\"plan\": \"hello\"}"
result = _strip_code_fences(text)
# no closing fence so it stays as-is (stripped)
result = _extract_json(text)
# no closing fence so brace-matching finds the JSON object
assert "plan" in result

def test_extracts_json_with_preamble(self):
text = 'Here is my plan.\n\n```json\n{"plan": "hello"}\n```'
assert _extract_json(text) == '{"plan": "hello"}'

def test_extracts_json_object_without_fences(self):
text = 'Now I have enough context.\n\n{"plan": "hello"}'
assert _extract_json(text) == '{"plan": "hello"}'

def test_handles_nested_braces_in_json(self):
text = 'Some text\n{"plan": "use {braces} here"}'
result = _extract_json(text)
assert '"plan"' in result
assert "{braces}" in result

def test_handles_escaped_quotes(self):
text = r'Preamble {"plan": "say \"hello\""}'
result = _extract_json(text)
assert '"plan"' in result


class TestParseResponse:
def test_parses_preliminary_plan(self):
Expand Down
Loading