diff --git a/codec_chat.html b/codec_chat.html index dccee96..96cac04 100644 --- a/codec_chat.html +++ b/codec_chat.html @@ -634,8 +634,38 @@

CODEC

function genId(){return Date.now().toString(36)+Math.random().toString(36).substr(2,5)} -function startNewSession(){sessionId=genId();chatHist=[];pendingFiles=[];document.getElementById('fileChips').innerHTML='';document.getElementById('messages').innerHTML='

Deep Chat \u2014 250K context window
Drop files, images, or just talk.

'} -startNewSession(); +function startNewSession(){sessionId=genId();localStorage.setItem('codec-chat-session',sessionId);chatHist=[];pendingFiles=[];document.getElementById('fileChips').innerHTML='';document.getElementById('messages').innerHTML='

Deep Chat \u2014 250K context window
Drop files, images, or just talk.

'} + +// PR #39: persist sessionId across page reloads. If a previous session +// exists, load it on boot so the user lands back on the same chat (with +// any project plan cards rehydrated). Falls back to a fresh session if +// the previous one's GET returns empty. +(function bootSession(){ + var saved=localStorage.getItem('codec-chat-session'); + if(!saved){startNewSession();return} + // Optimistically load \u2014 if the session is empty/missing, we'll start fresh + fetch('/api/qchat/session/'+saved).then(function(r){ + return r.ok?r.json():[]; + }).then(function(data){ + if(data&&data.length>0){ + // Use the saved id and replay history through the same code path + // sidebar uses (so the plan-card rehydration runs once the messages + // are in the DOM). + sessionId=saved; + var es=document.getElementById('emptyState');if(es)es.remove(); + document.getElementById('messages').innerHTML=''; + chatHist=[]; + for(var i=0;iCODEC } async function loadSession(sid){ - closeSidebar();sessionId=sid;chatHist=[];pendingFiles=[]; + closeSidebar();sessionId=sid;localStorage.setItem('codec-chat-session',sid);chatHist=[];pendingFiles=[]; document.getElementById('fileChips').innerHTML=''; var es=document.getElementById('emptyState');if(es)es.remove(); document.getElementById('messages').innerHTML=''; try{ var r=await fetch('/api/qchat/session/'+sid);var data=await r.json(); for(var i=0;i] marker + // gets replaced by the live plan card (fetched from /api/agents/). + await rehydrateAgentPlanCards(); scrollBottom() }catch(e){} } @@ -743,6 +776,119 @@

CODEC

} } var addMsg=addMessage; // alias for webcam code + +// ─────────────────────────────────────────────────────────────────────────── +// PR #39 — Agent plan persistence (Project mode) +// ─────────────────────────────────────────────────────────────────────────── +// Why this exists: Phase 3.5 saved only the bare string "Project drafted: +// agent_xxx" to chat history; the inline card with approve/reject/view-plan +// buttons lived only in the DOM. Reloading the chat lost the card. +// +// Approach: persist a marker token `[CODEC_AGENT_PLAN:]` inside the +// assistant message content. On chat session load, scan rendered messages, +// detect the marker, fetch the agent state, and replace the text bubble +// with the same card the live flow renders. +// +// The card's button callbacks (approveAgentInChat / rejectAgentInChat / +// viewAgentPlan) only need agent_id, so reload-time rendering matches the +// live render exactly. Status-aware buttons (e.g. "Resume" on +// blocked_on_qwen) come for free via the agent state object. +var AGENT_PLAN_MARKER_RE=/\[CODEC_AGENT_PLAN:(agent_[a-z0-9]+)\]/; + +function extractAgentIdFromMessage(content){ + if(!content||typeof content!=='string')return null; + var m=content.match(AGENT_PLAN_MARKER_RE); + return m?m[1]:null; +} + +function renderAgentPlanCard(agentId,agentInfo){ + // agentInfo accepts two shapes: + // - POST /api/agents result: {agent_id, status, project_dir} (flat) + // - GET /api/agents/: {manifest: {agent_id, status, ...}, plan, state, grants} + // Normalize both into the same view-model. + agentInfo=agentInfo||{}; + var manifest=agentInfo.manifest||agentInfo; // GET wraps in .manifest, POST is flat + var status=manifest.status||''; + var reason=manifest.status_reason||''; + var projectDir=manifest.project_dir||''; + var folderHtml=''; + if(projectDir){ + folderHtml='
'+ + '
Project folder
'+ + ''+escHtml(projectDir)+''+ + '
Open this folder in your IDE to see the agent\'s files as they\'re created. (cmd+click to open in Finder)
'+ + '
'; + } + // Status-aware action buttons. draft_pending / awaiting_approval = + // approve+reject+view; running/done/aborted = view-only; blocked_* = + // resume+abort+view. + var buttonsHtml=''; + var isPendingApproval=(!status)||status==='draft_pending'||status==='awaiting_approval'; + var isBlocked=status&&status.indexOf('blocked_')===0; + var isTerminal=status==='done'||status==='aborted'||status==='plan_failed'; + if(isPendingApproval){ + buttonsHtml= + ''+ + ''+ + ''; + }else if(isBlocked){ + buttonsHtml= + ''+ + ''; + }else{ + buttonsHtml= + ''; + } + var statusLine=''; + if(status){ + var color=isTerminal?'var(--text-dim)':(isBlocked?'#f87171':'#10b981'); + var label=status.replace(/_/g,' '); + statusLine='
status: '+escHtml(label)+''+(reason?' ('+escHtml(reason)+')':'')+'
'; + } + var subline=isPendingApproval + ?'A plan with a permission manifest has been written. Review and approve to let the agent run autonomously.' + :(isTerminal?'Agent finished. View plan to see what ran.':'Agent state shown above; click View plan for live progress.'); + return'
Project drafted
'+ + '
agent_id: '+escHtml(agentId)+'
'+ + statusLine+ + folderHtml+ + '
'+subline+'
'+ + '
'+buttonsHtml+'
'; +} + +async function rehydrateAgentPlanCards(){ + // Called after loadSession finishes rendering text messages. Scan + // assistant bubbles for the marker, fetch each agent in parallel, + // replace the bubble with the rendered card. Failures fall back + // to leaving the marker text visible (so the user knows something + // is missing rather than seeing nothing). + var msgs=document.querySelectorAll('#messages .msg.assistant .msg-bubble'); + var jobs=[]; + for(var i=0;i returns {manifest, plan, state, grants}. + // Treat presence of manifest.agent_id as "agent exists". + var ok=info&&info.manifest&&info.manifest.agent_id; + if(ok){ + b.innerHTML=renderAgentPlanCard(id,info); + }else{ + // Agent missing (e.g. manifest deleted); show inline notice + // but keep the agent_id visible so the user knows what's gone. + b.innerHTML='
Project: '+escHtml(id)+'
'+ + '
Agent state not found — manifest may have been removed.
'; + } + }).catch(function(){/* keep raw text on error */}); + })(bubble,agentId)); + } + if(jobs.length){await Promise.all(jobs)} +} + function scrollBottom(){var m=document.getElementById('messages');m.scrollTop=m.scrollHeight} function showTyping(){var div=document.createElement('div');div.className='typing';div.id='typing';div.innerHTML='\u25CF\u25CF\u25CF CODEC is thinking...';document.getElementById('messages').appendChild(div);scrollBottom()} function hideTyping(){var t=document.getElementById('typing');if(t)t.remove()} @@ -798,25 +944,14 @@

CODEC

pendingDiv.querySelector('.msg-bubble').innerHTML='Plan failed: '+escHtml(pd.detail); chatHist.push({role:'assistant',content:'Plan failed: '+pd.detail}); }else if(pd.agent_id){ - var folderHtml=''; - if(pd.project_dir){ - folderHtml='
'+ - '
Project folder created
'+ - ''+escHtml(pd.project_dir)+''+ - '
Open this folder in your IDE to see the agent\'s files as they\'re created. (cmd+click to open in Finder)
'+ - '
'; - } - var html='
Project drafted
'+ - '
agent_id: '+escHtml(pd.agent_id)+'
'+ - folderHtml+ - '
A plan with a permission manifest has been written. Review and approve to let the agent run autonomously.
'+ - '
'+ - ''+ - ''+ - ''+ - '
'; - pendingDiv.querySelector('.msg-bubble').innerHTML=html; - chatHist.push({role:'assistant',content:'Project drafted: '+pd.agent_id}); + pendingDiv.querySelector('.msg-bubble').innerHTML=renderAgentPlanCard(pd.agent_id, pd); + // PR #39: persist a marker token so loadSession() can re-render the + // card on reload. Plain text content kept short and human-readable + // for users on screens that don't grok the marker (e.g. text export). + var planMarker='[CODEC_AGENT_PLAN:'+pd.agent_id+']'; + var planText='Project drafted — agent_id '+pd.agent_id+' '+planMarker; + chatHist.push({role:'assistant',content:planText}); + saveMessages([{role:'assistant',content:planText}]); refreshAgentStatusPills(); }else{ pendingDiv.querySelector('.msg-bubble').innerHTML='Unknown response'; diff --git a/tests/test_chat_plan_persistence.py b/tests/test_chat_plan_persistence.py new file mode 100644 index 0000000..62ffbcd --- /dev/null +++ b/tests/test_chat_plan_persistence.py @@ -0,0 +1,168 @@ +"""PR #39 — Verify the [CODEC_AGENT_PLAN:] marker survives the +qchat save/load round-trip and is parseable by the same regex codec_chat.html +uses on the client. + +Why this test exists: +The chat used to persist only "Project drafted: agent_xxx" as plain text +when the user dropped a project (codec_chat.html line 819 pre-PR-#39). +The plan card with approve/reject/view buttons lived only in the DOM, so +any reload (refresh, sidebar click, hard navigation) lost it. + +PR #39 changes the persisted content to include a marker token: + "Project drafted — agent_id [CODEC_AGENT_PLAN:]" +On chat session load, JS scans assistant bubbles for the marker, fetches +the agent state from /api/agents/, and re-renders the plan card. + +This test verifies two invariants: + 1. SQLite (qchat_messages) preserves the marker byte-for-byte through + INSERT then SELECT — no quoting, escaping, or sanitization mangles it. + 2. The Python equivalent of the JS regex (`AGENT_PLAN_MARKER_RE`) + extracts the same agent_id the JS would, so the contract is locked. + +The test does NOT import codec_dashboard (avoids the pynput import chain +that's environment-specific). It re-creates the qchat schema in a temp +sqlite and exercises the same INSERT/SELECT shape the production handler +uses (codec_dashboard.py:1453-1457 and 1440).""" +import re +import sqlite3 +import tempfile +from pathlib import Path +from datetime import datetime + + +# Mirror of the JS regex in codec_chat.html: +# var AGENT_PLAN_MARKER_RE=/\[CODEC_AGENT_PLAN:(agent_[a-z0-9]+)\]/; +PY_AGENT_PLAN_MARKER_RE = re.compile(r"\[CODEC_AGENT_PLAN:(agent_[a-z0-9]+)\]") + + +def _extract_agent_id(content: str) -> str: + """Python equivalent of the JS extractAgentIdFromMessage(). Locked to + the same regex so client + server tests agree.""" + if not content or not isinstance(content, str): + return "" + m = PY_AGENT_PLAN_MARKER_RE.search(content) + return m.group(1) if m else "" + + +def _make_qchat_db(path: Path) -> sqlite3.Connection: + """Create the qchat schema as it lives in codec_dashboard.qchat_db().""" + conn = sqlite3.connect(str(path), check_same_thread=False) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute('''CREATE TABLE IF NOT EXISTS qchat_sessions ( + id TEXT PRIMARY KEY, title TEXT, created_at TEXT, updated_at TEXT, + user_id TEXT DEFAULT 'default')''') + conn.execute('''CREATE TABLE IF NOT EXISTS qchat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, role TEXT, + content TEXT, timestamp TEXT, user_id TEXT DEFAULT 'default')''') + conn.commit() + return conn + + +def _save_message(conn, sid: str, role: str, content: str) -> None: + """Mimic /api/qchat/save inserts (codec_dashboard.py:1456).""" + now = datetime.now().isoformat() + conn.execute( + "INSERT OR REPLACE INTO qchat_sessions (id, title, created_at, updated_at, user_id) " + "VALUES (?, ?, ?, ?, ?)", + (sid, "test session", now, now, "default"), + ) + conn.execute( + "INSERT INTO qchat_messages (session_id, role, content, timestamp, user_id) " + "VALUES (?, ?, ?, ?, ?)", + (sid, role, content, now, "default"), + ) + conn.commit() + + +def _load_messages(conn, sid: str): + """Mimic /api/qchat/session/{sid} (codec_dashboard.py:1440).""" + rows = conn.execute( + "SELECT role, content FROM qchat_messages WHERE session_id=? ORDER BY id ASC", + (sid,), + ).fetchall() + return [{"role": r[0], "content": r[1]} for r in rows] + + +# ───────────────────────────────────────────────────────────────────────────── +# Tests +# ───────────────────────────────────────────────────────────────────────────── + +def test_extract_agent_id_finds_marker(): + """Same shape the JS regex produces.""" + content = "Project drafted — agent_id agent_abc123def [CODEC_AGENT_PLAN:agent_abc123def]" + assert _extract_agent_id(content) == "agent_abc123def" + + +def test_extract_agent_id_returns_empty_when_no_marker(): + assert _extract_agent_id("Just a normal chat message") == "" + assert _extract_agent_id("") == "" + assert _extract_agent_id(None) == "" + + +def test_extract_agent_id_ignores_malformed_markers(): + # Wrong prefix — must be lowercase agent_ + assert _extract_agent_id("[CODEC_AGENT_PLAN:Agent_xyz]") == "" + # Missing brackets + assert _extract_agent_id("CODEC_AGENT_PLAN:agent_xyz") == "" + # Missing agent_ prefix + assert _extract_agent_id("[CODEC_AGENT_PLAN:xyz]") == "" + + +def test_marker_survives_qchat_save_then_load_roundtrip(tmp_path): + """The end-to-end invariant PR #39 depends on: write a message with + the marker, read it back, marker text is unchanged byte-for-byte.""" + conn = _make_qchat_db(tmp_path / "qchat.db") + sid = "session_test_123" + agent_id = "agent_1416ea3e1b02" + written = ( + f"Project drafted — agent_id {agent_id} [CODEC_AGENT_PLAN:{agent_id}]" + ) + _save_message(conn, sid, "assistant", written) + msgs = _load_messages(conn, sid) + assert len(msgs) == 1 + assert msgs[0]["role"] == "assistant" + # Byte-for-byte identical + assert msgs[0]["content"] == written + # And extractor finds the same id + assert _extract_agent_id(msgs[0]["content"]) == agent_id + + +def test_multiple_messages_with_markers_in_one_session(tmp_path): + """A session can have many project drops; each marker must round-trip + independently and ordering is preserved.""" + conn = _make_qchat_db(tmp_path / "qchat.db") + sid = "session_multi" + ids = ["agent_aaa111", "agent_bbb222", "agent_ccc333"] + for aid in ids: + _save_message(conn, sid, "user", f"build me thing {aid[:6]}") + _save_message( + conn, sid, "assistant", + f"Project drafted — agent_id {aid} [CODEC_AGENT_PLAN:{aid}]", + ) + msgs = _load_messages(conn, sid) + # 3 user + 3 assistant + assert len(msgs) == 6 + assistant_ids = [ + _extract_agent_id(m["content"]) + for m in msgs if m["role"] == "assistant" + ] + assert assistant_ids == ids # ordering preserved + + +def test_marker_extracts_real_world_agent_id_format(tmp_path): + """Production agent_ids are 12 hex chars (e.g. agent_1416ea3e1b02 + seen in the 2026-05-03 forex audit). The regex `[a-z0-9]+` matches + that exactly. Underscores in the id portion correctly DO NOT match — + pins the contract that ids are hex-only after the agent_ prefix.""" + conn = _make_qchat_db(tmp_path / "qchat.db") + sid = "session_edge" + real_id = "agent_1416ea3e1b02" + _save_message( + conn, sid, "assistant", + f"Project drafted — agent_id {real_id} [CODEC_AGENT_PLAN:{real_id}]", + ) + msgs = _load_messages(conn, sid) + assert _extract_agent_id(msgs[0]["content"]) == real_id + + # Underscored "ids" are correctly rejected — locks the format + assert _extract_agent_id("[CODEC_AGENT_PLAN:agent_with_underscore]") == ""