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:
@@ -155,7 +155,7 @@ class EcoHandlers:
|
|||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
text = content.replace('[EXTERNAL CONTENT]\n', '').replace('\n[END EXTERNAL CONTENT]', '').strip()
|
text = content.replace('[EXTERNAL CONTENT]\n', '').replace('\n[END EXTERNAL CONTENT]', '').strip()
|
||||||
if text:
|
if text:
|
||||||
messages.append({'role': 'user', 'text': text[:2000]})
|
messages.append({'role': 'user', 'text': text[:20000]})
|
||||||
elif t == 'assistant':
|
elif t == 'assistant':
|
||||||
msg = d.get('message', {})
|
msg = d.get('message', {})
|
||||||
content = msg.get('content', '')
|
content = msg.get('content', '')
|
||||||
@@ -163,9 +163,9 @@ class EcoHandlers:
|
|||||||
parts = [block['text'] for block in content if block.get('type') == 'text']
|
parts = [block['text'] for block in content if block.get('type') == 'text']
|
||||||
text = '\n'.join(parts).strip()
|
text = '\n'.join(parts).strip()
|
||||||
if text:
|
if text:
|
||||||
messages.append({'role': 'assistant', 'text': text[:2000]})
|
messages.append({'role': 'assistant', 'text': text[:20000]})
|
||||||
elif isinstance(content, str) and content.strip():
|
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})
|
self.send_json({'messages': messages})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -248,6 +249,102 @@ def _last_message_preview(slug: str) -> str:
|
|||||||
return best[:200]
|
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 ──────────────────────────────────────────────────
|
# ─── version helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -929,6 +1026,8 @@ class ProjectsHandlers:
|
|||||||
final_plan_text = fp.read_text(encoding="utf-8", errors="replace")
|
final_plan_text = fp.read_text(encoding="utf-8", errors="replace")
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
session_id = (state or {}).get("session_id") or ""
|
||||||
|
history = _load_planning_history(slug, session_id)
|
||||||
self.send_json({
|
self.send_json({
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"phase": (state or {}).get("phase"),
|
"phase": (state or {}).get("phase"),
|
||||||
@@ -936,7 +1035,7 @@ class ProjectsHandlers:
|
|||||||
"last_text_excerpt": (state or {}).get("last_text_excerpt") or "",
|
"last_text_excerpt": (state or {}).get("last_text_excerpt") or "",
|
||||||
"final_plan_path": final_plan_path,
|
"final_plan_path": final_plan_path,
|
||||||
"final_plan": final_plan_text,
|
"final_plan": final_plan_text,
|
||||||
"history": [], # reserved for future per-turn JSONL sidecar
|
"history": history,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── POST /api/projects/<slug>/plan/finalize ───────────────────
|
# ── POST /api/projects/<slug>/plan/finalize ───────────────────
|
||||||
|
|||||||
@@ -1954,18 +1954,29 @@
|
|||||||
// ──────────────────────────────────────────
|
// ──────────────────────────────────────────
|
||||||
const PHASES = ['office-hours', 'ceo-review', 'eng-review'];
|
const PHASES = ['office-hours', 'ceo-review', 'eng-review'];
|
||||||
|
|
||||||
|
function normalizePhase(phase) {
|
||||||
|
return (phase || '').replace(/^\//, '').replace(/^plan-/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function setPhase(phase, completed) {
|
function setPhase(phase, completed) {
|
||||||
state.planning.phase = phase;
|
state.planning.phase = phase;
|
||||||
state.planning.phasesCompleted = completed || [];
|
state.planning.phasesCompleted = completed || [];
|
||||||
|
const norm = normalizePhase(phase);
|
||||||
|
const completedNorm = (completed || []).map(normalizePhase);
|
||||||
const steps = phaseStepper.querySelectorAll('.phase-step');
|
const steps = phaseStepper.querySelectorAll('.phase-step');
|
||||||
steps.forEach(step => {
|
steps.forEach(step => {
|
||||||
const ph = step.dataset.phase;
|
const ph = step.dataset.phase;
|
||||||
step.classList.remove('active', 'complete');
|
step.classList.remove('active', 'complete');
|
||||||
if ((completed || []).indexOf(ph) >= 0) step.classList.add('complete');
|
if (completedNorm.indexOf(ph) >= 0) step.classList.add('complete');
|
||||||
if (ph === phase) step.classList.add('active');
|
if (ph === norm) step.classList.add('active');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripPhaseMarkers(text) {
|
||||||
|
if (!text) return text;
|
||||||
|
return text.replace(/\s*\nPHASE_STATUS:\s*(ready_to_advance|needs_input)\s*$/, '').trimEnd();
|
||||||
|
}
|
||||||
|
|
||||||
function renderMarkdown(text) {
|
function renderMarkdown(text) {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
try {
|
try {
|
||||||
@@ -1977,15 +1988,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendMessage(role, content) {
|
function appendMessage(role, content) {
|
||||||
state.planning.messages.push({ role, content, ts: Date.now() });
|
const display = role === 'assistant' ? stripPhaseMarkers(content) : content;
|
||||||
|
state.planning.messages.push({ role, content: display, ts: Date.now() });
|
||||||
const msg = document.createElement('div');
|
const msg = document.createElement('div');
|
||||||
msg.className = 'chat-msg ' + role;
|
msg.className = 'chat-msg ' + role;
|
||||||
const bubble = document.createElement('div');
|
const bubble = document.createElement('div');
|
||||||
bubble.className = 'chat-bubble';
|
bubble.className = 'chat-bubble';
|
||||||
if (role === 'assistant') {
|
if (role === 'assistant') {
|
||||||
bubble.innerHTML = renderMarkdown(content);
|
bubble.innerHTML = renderMarkdown(display);
|
||||||
} else {
|
} else {
|
||||||
bubble.textContent = content;
|
bubble.textContent = display;
|
||||||
}
|
}
|
||||||
msg.appendChild(bubble);
|
msg.appendChild(bubble);
|
||||||
chatStream.appendChild(msg);
|
chatStream.appendChild(msg);
|
||||||
@@ -2075,14 +2087,24 @@
|
|||||||
if (tRes.ok) {
|
if (tRes.ok) {
|
||||||
const data = await safeJson(tRes);
|
const data = await safeJson(tRes);
|
||||||
if (data) {
|
if (data) {
|
||||||
if (data.last_text_excerpt) {
|
let rendered = false;
|
||||||
|
if (Array.isArray(data.history) && data.history.length > 0) {
|
||||||
|
for (const m of data.history) {
|
||||||
|
if (m && m.text) appendMessage(m.role || 'assistant', m.text);
|
||||||
|
}
|
||||||
|
rendered = true;
|
||||||
|
} else if (data.last_text_excerpt) {
|
||||||
appendMessage('assistant', data.last_text_excerpt);
|
appendMessage('assistant', data.last_text_excerpt);
|
||||||
|
rendered = true;
|
||||||
}
|
}
|
||||||
const phasesCompleted = data.phases_completed || [];
|
const phasesCompleted = data.phases_completed || [];
|
||||||
const phase = data.phase || 'office-hours';
|
const phase = data.phase || 'office-hours';
|
||||||
setPhase(phase === '__complete__' ? null : phase, phasesCompleted);
|
setPhase(phase === '__complete__' ? null : phase, phasesCompleted);
|
||||||
// If we already have content, we're resuming
|
// If we already have content, we're resuming
|
||||||
if (data.last_text_excerpt) {
|
if (rendered) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
chatStream.scrollTop = chatStream.scrollHeight;
|
||||||
|
});
|
||||||
try { composerInput.focus(); } catch (e) {}
|
try { composerInput.focus(); } catch (e) {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2189,6 +2211,9 @@
|
|||||||
}
|
}
|
||||||
setPhase(data.phase === '__complete__' ? null : data.phase, completed);
|
setPhase(data.phase === '__complete__' ? null : data.phase, completed);
|
||||||
}
|
}
|
||||||
|
if (data.phase_ready) {
|
||||||
|
await autoAdvancePhase(slug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
removeTypingIndicator();
|
removeTypingIndicator();
|
||||||
@@ -2203,6 +2228,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function autoAdvancePhase(slug) {
|
||||||
|
const prevPhase = state.planning.phase;
|
||||||
|
appendMessage('assistant', '_Faza completă. Pornesc subprocess pentru următoarea fază..._');
|
||||||
|
appendTypingIndicator();
|
||||||
|
startElapsedCounter();
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/echo/api/projects/' + encodeURIComponent(slug) + '/plan/advance', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
removeTypingIndicator();
|
||||||
|
stopElapsedCounter();
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await safeJson(res);
|
||||||
|
appendMessage('assistant', '_Nu am putut avansa: ' + ((d && d.message) || ('HTTP ' + res.status)) + '_');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await safeJson(res);
|
||||||
|
if (!data) return;
|
||||||
|
const completed = state.planning.phasesCompleted.slice();
|
||||||
|
if (prevPhase && !completed.includes(prevPhase)) completed.push(prevPhase);
|
||||||
|
if (data.completed) {
|
||||||
|
setPhase(null, completed);
|
||||||
|
if (data.message) appendMessage('assistant', data.message);
|
||||||
|
} else {
|
||||||
|
if (data.message) appendMessage('assistant', data.message);
|
||||||
|
setPhase(data.phase, completed);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
removeTypingIndicator();
|
||||||
|
stopElapsedCounter();
|
||||||
|
if (err.message !== 'unauthorized') {
|
||||||
|
appendMessage('assistant', '_Eroare la avans fază: ' + (err.message || err) + '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
planCancelBtn.addEventListener('click', async () => {
|
planCancelBtn.addEventListener('click', async () => {
|
||||||
const slug = state.planning.slug;
|
const slug = state.planning.slug;
|
||||||
if (!slug) { closeModal(planModal); return; }
|
if (!slug) { closeModal(planModal); return; }
|
||||||
|
|||||||
@@ -25,24 +25,39 @@ dacă scrie EN, mergi EN). Concis: 3-6 propoziții per turn, nu eseuri.
|
|||||||
|
|
||||||
Faza curentă e numele unui skill gstack (`/office-hours`, `/plan-ceo-review`,
|
Faza curentă e numele unui skill gstack (`/office-hours`, `/plan-ceo-review`,
|
||||||
`/plan-eng-review`, `/plan-design-review`). Când primești prima invocare a fazei, urmează skill-ul
|
`/plan-eng-review`, `/plan-design-review`). Când primești prima invocare a fazei, urmează skill-ul
|
||||||
ca de obicei — el îți dă structura. Nu re-rula skill-ul în interiorul aceleiași sesiuni decât
|
ca de obicei — el îți dă structura.
|
||||||
dacă Marius cere explicit.
|
|
||||||
|
|
||||||
Fiecare fază rulează într-un **subprocess Claude separat** (fresh `claude -p`). Sesiunea
|
**INTERZIS** să rulezi `/plan-ceo-review`, `/plan-eng-review` sau `/plan-design-review`
|
||||||
precedentă a salvat un artifact pe disc (`~/.gstack/projects/{slug}/...`); citește-l ca să nu îl
|
ca tool-call în interiorul fazei curente. Fiecare fază rulează într-un **subprocess Claude
|
||||||
întrebi pe Marius lucruri lămurite deja.
|
separat** (fresh `claude -p`) pe care îl pornește orchestratorul. Dacă te simți „gata" la
|
||||||
|
office-hours, nu continua singur cu CEO review — închide turnul cu marker-ul `ready_to_advance`
|
||||||
|
și orchestratorul va porni următorul subprocess. Asta e singura cale prin care
|
||||||
|
`phases_completed` din `sessions/planning.json` crește și UI-ul îți arată progresul real.
|
||||||
|
|
||||||
|
Sesiunea precedentă a salvat un artifact pe disc (`~/.gstack/projects/{slug}/...`); citește-l
|
||||||
|
ca să nu îl întrebi pe Marius lucruri lămurite deja.
|
||||||
|
|
||||||
## Reguli de output
|
## Reguli de output
|
||||||
|
|
||||||
1. **Întrebări pentru Marius** — pune-i 1–3 întrebări la rând, nu 10. AskUserQuestion gstack se
|
1. **Întrebări pentru Marius** — pune-i 1–3 întrebări la rând, nu 10. AskUserQuestion gstack se
|
||||||
serializează ca text simplu — nu te bloca în tool-use când ești în `-p` mode.
|
serializează ca text simplu — nu te bloca în tool-use când ești în `-p` mode.
|
||||||
2. **Marker de progres** — când consideri faza completă în mintea ta, închide turnul cu o
|
2. **Marker de progres — OBLIGATORIU pe ultima linie a FIECĂRUI turn**, fără excepție.
|
||||||
linie pe ultim rand:
|
Două valori posibile:
|
||||||
|
- `PHASE_STATUS: needs_input` — încă mai ai nevoie de informații/clarificări de la Marius
|
||||||
|
în această fază. Default pentru orice turn intermediar.
|
||||||
|
- `PHASE_STATUS: ready_to_advance` — faza e completă în mintea ta, artefactul e scris pe
|
||||||
|
disc, e clar ce urmează. Echo o citește și avansează automat la faza următoare
|
||||||
|
(subprocess proaspăt, fără click suplimentar din partea lui Marius).
|
||||||
|
Marker-ul trebuie să fie **ultima linie** din răspuns, fără text după el. Fără el, sistemul
|
||||||
|
presupune `needs_input` și rămâi blocat în faza curentă.
|
||||||
|
|
||||||
|
Exemplu de tranziție corectă la sfârșit de office-hours:
|
||||||
```
|
```
|
||||||
|
Am surprins toate aspectele esențiale. Salvez artifact-ul în ~/.gstack/projects/{slug}/
|
||||||
|
user-mariusm-master-office-hours-summary.md și sunt gata pentru CEO review.
|
||||||
|
|
||||||
PHASE_STATUS: ready_to_advance
|
PHASE_STATUS: ready_to_advance
|
||||||
```
|
```
|
||||||
Echo (orchestratorul) o citește și îi prezintă lui Marius butonul „Continuă faza".
|
|
||||||
Dacă mai ai nevoie de input, închide cu `PHASE_STATUS: needs_input`.
|
|
||||||
3. **Artifact pe disc** — la sfârșitul fazei tale, scrie sau actualizează artifactul în
|
3. **Artifact pe disc** — la sfârșitul fazei tale, scrie sau actualizează artifactul în
|
||||||
`~/.gstack/projects/{slug}/{user}-{phase}-...md` conform convenției skill-ului. Nu inventa
|
`~/.gstack/projects/{slug}/{user}-{phase}-...md` conform convenției skill-ului. Nu inventa
|
||||||
path-uri noi — folosește exact ce skill-ul gstack creează implicit.
|
path-uri noi — folosește exact ce skill-ul gstack creează implicit.
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ PHASE_NEEDS_INPUT_MARKER = "PHASE_STATUS: needs_input"
|
|||||||
# "planning_session_id": "<echo internal uuid>",
|
# "planning_session_id": "<echo internal uuid>",
|
||||||
# "started_at": "...",
|
# "started_at": "...",
|
||||||
# "updated_at": "...",
|
# "updated_at": "...",
|
||||||
# "last_text_excerpt": "...", # 500 char excerpt for debugging
|
# "last_text_excerpt": "...", # ≤50K char fallback excerpt; full transcript lives in Claude's session JSONL
|
||||||
# "last_subtype": "success" | "error_max_turns" | ...,
|
# "last_subtype": "success" | "error_max_turns" | ...,
|
||||||
# }
|
# }
|
||||||
# }
|
# }
|
||||||
@@ -418,7 +418,7 @@ class PlanningSession:
|
|||||||
"channel_id": self.channel_id,
|
"channel_id": self.channel_id,
|
||||||
"started_at": existing.get("started_at", now),
|
"started_at": existing.get("started_at", now),
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
"last_text_excerpt": (self._last_response or "")[:500],
|
"last_text_excerpt": (self._last_response or "")[:50000],
|
||||||
"last_subtype": self._last_subtype,
|
"last_subtype": self._last_subtype,
|
||||||
"total_cost_usd": (
|
"total_cost_usd": (
|
||||||
float(existing.get("total_cost_usd") or 0.0) + float(cost_usd or 0.0)
|
float(existing.get("total_cost_usd") or 0.0) + float(cost_usd or 0.0)
|
||||||
|
|||||||
Reference in New Issue
Block a user