feat(planning): full chat history + auto-advance phases

Three fixes that together restore the planning UX:

- Dashboard reopen showed only a 500-char truncated excerpt of the last
  assistant message. Backend now reads the Claude session JSONL directly
  and returns full per-turn history; frontend iterates and renders all
  bubbles, falling back to last_text_excerpt when the JSONL is missing.
- Phases never advanced because the agent ran /plan-* skills inline as
  tool calls and the marker protocol was loose. Tightened the planning
  prompt (mandatory PHASE_STATUS marker on the last line of every turn,
  ban on inline phase invocation), and the frontend now auto-calls
  /plan/advance when phase_ready=true.
- The phase strip never showed visual state because data-phase values
  ("office-hours") didn't match orchestrator phase names ("/office-hours").
  Added normalizePhase + cleanup of PHASE_STATUS markers from rendered
  bubbles.

Also bumps eco.py session-content truncation from 2k to 20k so /eco
session views aren't cut mid-response either.

Bumps last_text_excerpt fallback in planning_session.py from 500 to
50_000 so even when the JSONL is unavailable, the bubble isn't sliced
mid-word.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 07:47:10 +00:00
parent d0faeed181
commit 8432fe3150
5 changed files with 197 additions and 22 deletions

View File

@@ -155,7 +155,7 @@ class EcoHandlers:
if isinstance(content, str):
text = content.replace('[EXTERNAL CONTENT]\n', '').replace('\n[END EXTERNAL CONTENT]', '').strip()
if text:
messages.append({'role': 'user', 'text': text[:2000]})
messages.append({'role': 'user', 'text': text[:20000]})
elif t == 'assistant':
msg = d.get('message', {})
content = msg.get('content', '')
@@ -163,9 +163,9 @@ class EcoHandlers:
parts = [block['text'] for block in content if block.get('type') == 'text']
text = '\n'.join(parts).strip()
if text:
messages.append({'role': 'assistant', 'text': text[:2000]})
messages.append({'role': 'assistant', 'text': text[:20000]})
elif isinstance(content, str) and content.strip():
messages.append({'role': 'assistant', 'text': content[:2000]})
messages.append({'role': 'assistant', 'text': content[:20000]})
self.send_json({'messages': messages})
except Exception as e:

View File

@@ -43,6 +43,7 @@ import hashlib
import json
import logging
import os
import re
import subprocess
import sys
import time
@@ -248,6 +249,102 @@ def _last_message_preview(slug: str) -> str:
return best[:200]
# ─── planning transcript helpers ──────────────────────────────────────
_EXTERNAL_CONTENT_RE = re.compile(
r"^\s*\[EXTERNAL CONTENT\]\s*\n(.*)\n\[END EXTERNAL CONTENT\]\s*$",
re.DOTALL,
)
_COMMAND_ARGS_RE = re.compile(r"<command-args>(.*?)</command-args>", re.DOTALL)
def _planning_jsonl_path(slug: str, session_id: str) -> Path | None:
"""Path to the Claude CLI JSONL transcript for a planning session.
PlanningSession runs with cwd = `~/workspace/<slug>/` if it exists, else
falls back to the echo-core repo root (see `PlanningSession.cwd`).
Claude CLI stores transcripts under
`~/.claude/projects/<encoded-cwd>/<session_id>.jsonl`, where encoded-cwd
is the absolute path with `/` replaced by `-`.
"""
if not session_id:
return None
workspace = constants.WORKSPACE_DIR / slug
cwd = workspace if workspace.is_dir() else constants.BASE_DIR
encoded = str(cwd).replace("/", "-")
return Path.home() / ".claude" / "projects" / encoded / f"{session_id}.jsonl"
def _clean_user_text(text: str) -> str:
"""Strip wrappers Echo adds around user input sent to the planning subprocess."""
m = _EXTERNAL_CONTENT_RE.match(text)
if m:
text = m.group(1).strip()
if "<command-name>" in text:
m = _COMMAND_ARGS_RE.search(text)
if m:
text = m.group(1).strip()
return text
def _extract_text_blocks(content) -> str:
"""Concatenate `text`-type blocks from a Claude `message.content` field."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
t = block.get("text") or ""
if t:
parts.append(t)
return "\n\n".join(parts)
return ""
def _load_planning_history(slug: str, session_id: str | None) -> list[dict]:
"""Parse the Claude session JSONL into a `[{role, text}, ...]` chat history.
Skips queue / system / tool-result entries. Empty list on any structural
problem so callers can fall back to `last_text_excerpt`.
"""
if not session_id:
return []
path = _planning_jsonl_path(slug, session_id)
if path is None or not path.exists():
return []
history: list[dict] = []
try:
with path.open(encoding="utf-8") as fh:
for raw in fh:
raw = raw.strip()
if not raw:
continue
try:
entry = json.loads(raw)
except json.JSONDecodeError:
continue
etype = entry.get("type")
if etype not in ("user", "assistant"):
continue
if etype == "user" and "toolUseResult" in entry:
continue
if entry.get("isMeta"):
continue
msg = entry.get("message") or {}
text = _extract_text_blocks(msg.get("content"))
if etype == "user":
text = _clean_user_text(text)
text = (text or "").strip()
if not text:
continue
history.append({"role": etype, "text": text})
except OSError:
return []
return history
# ─── version helpers ──────────────────────────────────────────────────
@@ -929,6 +1026,8 @@ class ProjectsHandlers:
final_plan_text = fp.read_text(encoding="utf-8", errors="replace")
except OSError:
pass
session_id = (state or {}).get("session_id") or ""
history = _load_planning_history(slug, session_id)
self.send_json({
"slug": slug,
"phase": (state or {}).get("phase"),
@@ -936,7 +1035,7 @@ class ProjectsHandlers:
"last_text_excerpt": (state or {}).get("last_text_excerpt") or "",
"final_plan_path": final_plan_path,
"final_plan": final_plan_text,
"history": [], # reserved for future per-turn JSONL sidecar
"history": history,
})
# ── POST /api/projects/<slug>/plan/finalize ───────────────────