diff --git a/api/models.py b/api/models.py index be6b68e..5a51593 100644 --- a/api/models.py +++ b/api/models.py @@ -34,17 +34,65 @@ def _write_session_index(): class Session: - def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, profile=None, **kwargs): - self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None; self.profile = profile + def __init__(self, session_id=None, title='Untitled', + workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, + messages=None, created_at=None, updated_at=None, + tool_calls=None, pinned=False, archived=False, + project_id=None, profile=None, + input_tokens=0, output_tokens=0, estimated_cost=None, + **kwargs): + self.session_id = session_id or uuid.uuid4().hex[:12] + self.title = title + self.workspace = str(Path(workspace).expanduser().resolve()) + self.model = model + self.messages = messages or [] + self.tool_calls = tool_calls or [] + self.created_at = created_at or time.time() + self.updated_at = updated_at or time.time() + self.pinned = bool(pinned) + self.archived = bool(archived) + self.project_id = project_id or None + self.profile = profile + self.input_tokens = input_tokens or 0 + self.output_tokens = output_tokens or 0 + self.estimated_cost = estimated_cost + @property - def path(self): return SESSION_DIR / f'{self.session_id}.json' - def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index() + def path(self): + return SESSION_DIR / f'{self.session_id}.json' + + def save(self): + self.updated_at = time.time() + self.path.write_text( + json.dumps(self.__dict__, ensure_ascii=False, indent=2), + encoding='utf-8', + ) + _write_session_index() + @classmethod def load(cls, sid): p = SESSION_DIR / f'{sid}.json' - if not p.exists(): return None + if not p.exists(): + return None return cls(**json.loads(p.read_text(encoding='utf-8'))) - def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, 'profile': self.profile} + + def compact(self): + return { + 'session_id': self.session_id, + 'title': self.title, + 'workspace': self.workspace, + 'model': self.model, + 'message_count': len(self.messages), + 'created_at': self.created_at, + 'updated_at': self.updated_at, + 'pinned': self.pinned, + 'archived': self.archived, + 'project_id': self.project_id, + 'profile': self.profile, + 'input_tokens': self.input_tokens, + 'output_tokens': self.output_tokens, + 'estimated_cost': self.estimated_cost, + } def get_session(sid): with LOCK: diff --git a/api/routes.py b/api/routes.py index f0bafc5..d0714e1 100644 --- a/api/routes.py +++ b/api/routes.py @@ -223,11 +223,26 @@ def handle_get(handler, parsed): return j(handler, {'skills': data.get('skills', [])}) if parsed.path == '/api/skills/content': - from tools.skills_tool import skill_view as _skill_view - name = parse_qs(parsed.query).get('name', [''])[0] + from tools.skills_tool import skill_view as _skill_view, SKILLS_DIR + qs = parse_qs(parsed.query) + name = qs.get('name', [''])[0] if not name: return j(handler, {'error': 'name required'}, status=400) + file_path = qs.get('file', [''])[0] + if file_path: + # Serve a linked file from the skill directory + skill_dir = None + for p in SKILLS_DIR.rglob(name): + if p.is_dir(): skill_dir = p; break + if not skill_dir: return bad(handler, 'Skill not found', 404) + target = (skill_dir / file_path).resolve() + try: target.relative_to(skill_dir.resolve()) + except ValueError: return bad(handler, 'Invalid file path', 400) + if not target.exists() or not target.is_file(): + return bad(handler, 'File not found', 404) + return j(handler, {'content': target.read_text(encoding='utf-8'), 'path': file_path}) raw = _skill_view(name) data = json.loads(raw) if isinstance(raw, str) else raw + if 'linked_files' not in data: data['linked_files'] = {} return j(handler, data) # ── Memory API (GET) ── diff --git a/api/streaming.py b/api/streaming.py index d087fd0..29e4f48 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -171,11 +171,20 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta ) s.messages = result.get('messages') or s.messages s.title = title_from(s.messages, s.title) + # Read token/cost usage from the agent object (if available) + input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0 + output_tokens = getattr(agent, 'session_completion_tokens', 0) or 0 + estimated_cost = getattr(agent, 'session_estimated_cost_usd', None) + s.input_tokens = (s.input_tokens or 0) + input_tokens + s.output_tokens = (s.output_tokens or 0) + output_tokens + if estimated_cost: + s.estimated_cost = (s.estimated_cost or 0) + estimated_cost # Extract tool call metadata grouped by assistant message index # Each tool call gets assistant_msg_idx so the client can render # cards inline with the assistant bubble that triggered them. tool_calls = [] pending_names = {} # tool_call_id -> name + pending_args = {} # tool_call_id -> args dict pending_asst_idx = {} # tool_call_id -> index in s.messages for msg_idx, m in enumerate(s.messages): if m.get('role') == 'assistant': @@ -184,22 +193,31 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta for p in c: if isinstance(p, dict) and p.get('type') == 'tool_use': tid = p.get('id', '') - pending_names[tid] = p.get('name', 'tool') + pending_names[tid] = p.get('name', '') + pending_args[tid] = p.get('input', {}) pending_asst_idx[tid] = msg_idx elif m.get('role') == 'tool': tid = m.get('tool_call_id') or m.get('tool_use_id', '') - name = pending_names.get(tid, 'tool') + name = pending_names.get(tid, '') + if not name or name == 'tool': + continue # skip unresolvable tool entries asst_idx = pending_asst_idx.get(tid, -1) + args = pending_args.get(tid, {}) raw = str(m.get('content', '')) try: - import json as _j2 - rd = _j2.loads(raw) + rd = json.loads(raw) snippet = str(rd.get('output') or rd.get('result') or rd.get('error') or raw)[:200] except Exception: snippet = raw[:200] + # Truncate args values for storage + args_snap = {} + if isinstance(args, dict): + for k, v in list(args.items())[:6]: + s2 = str(v) + args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '') tool_calls.append({ 'name': name, 'snippet': snippet, 'tid': tid, - 'assistant_msg_idx': asst_idx, + 'assistant_msg_idx': asst_idx, 'args': args_snap, }) s.tool_calls = tool_calls # Tag the matching user message with attachment filenames for display on reload @@ -215,7 +233,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta m['attachments'] = attachments break s.save() - put('done', {'session': s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}}) + usage = {'input_tokens': input_tokens, 'output_tokens': output_tokens, 'estimated_cost': estimated_cost} + put('done', {'session': s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}, 'usage': usage}) finally: if old_cwd is None: os.environ.pop('TERMINAL_CWD', None) else: os.environ['TERMINAL_CWD'] = old_cwd diff --git a/static/index.html b/static/index.html index 4903dce..b501569 100644 --- a/static/index.html +++ b/static/index.html @@ -45,11 +45,16 @@ - +
+ + +
+
diff --git a/static/messages.js b/static/messages.js index 6ba2c80..82ac5f4 100644 --- a/static/messages.js +++ b/static/messages.js @@ -146,6 +146,7 @@ async function send(){ } if(S.session&&S.session.session_id===activeSid){ S.session=d.session;S.messages=d.session.messages||[]; + if(d.usage) S.lastUsage=d.usage; if(d.session.tool_calls&&d.session.tool_calls.length){ S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true})); } else { diff --git a/static/panels.js b/static/panels.js index ad9ad2a..bd13b69 100644 --- a/static/panels.js +++ b/static/panels.js @@ -79,6 +79,9 @@ async function loadCrons() { } catch(e) { box.innerHTML = `
Error: ${esc(e.message)}
`; } } +let _cronSelectedSkills=[]; +let _cronSkillsCache=null; + function toggleCronForm(){ const form=$('cronCreateForm'); if(!form)return; @@ -90,10 +93,65 @@ function toggleCronForm(){ $('cronFormPrompt').value=''; $('cronFormDeliver').value='local'; $('cronFormError').style.display='none'; + _cronSelectedSkills=[]; + _renderCronSkillTags(); + const search=$('cronFormSkillSearch'); + if(search)search.value=''; + // Pre-fetch skills for the picker + if(!_cronSkillsCache){ + api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[];}).catch(()=>{}); + } $('cronFormName').focus(); } } +function _renderCronSkillTags(){ + const wrap=$('cronFormSkillTags'); + if(!wrap)return; + wrap.innerHTML=''; + for(const name of _cronSelectedSkills){ + const tag=document.createElement('span'); + tag.className='skill-tag'; + tag.innerHTML=esc(name)+'×'; + wrap.appendChild(tag); + } +} + +// Skill search input handler +(function(){ + const setup=()=>{ + const search=$('cronFormSkillSearch'); + const dropdown=$('cronFormSkillDropdown'); + if(!search||!dropdown)return; + search.oninput=()=>{ + const q=search.value.trim().toLowerCase(); + if(!q||!_cronSkillsCache){dropdown.style.display='none';return;} + const matches=_cronSkillsCache.filter(s=> + !_cronSelectedSkills.includes(s.name)&& + (s.name.toLowerCase().includes(q)||(s.category||'').toLowerCase().includes(q)) + ).slice(0,8); + if(!matches.length){dropdown.style.display='none';return;} + dropdown.innerHTML=''; + for(const s of matches){ + const opt=document.createElement('div'); + opt.className='skill-opt'; + opt.textContent=s.name+(s.category?' ('+s.category+')':''); + opt.onclick=()=>{ + _cronSelectedSkills.push(s.name); + _renderCronSkillTags(); + search.value=''; + dropdown.style.display='none'; + }; + dropdown.appendChild(opt); + } + dropdown.style.display=''; + }; + search.onblur=()=>setTimeout(()=>{dropdown.style.display='none';},150); + }; + if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',setup); + else setTimeout(setup,0); +})(); + async function submitCronCreate(){ const name=$('cronFormName').value.trim(); const schedule=$('cronFormSchedule').value.trim(); @@ -104,7 +162,10 @@ async function submitCronCreate(){ if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;} if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;} try{ - await api('/api/crons/create',{method:'POST',body:JSON.stringify({name:name||undefined,schedule,prompt,deliver})}); + const body={schedule,prompt,deliver}; + if(name)body.name=name; + if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills; + await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)}); toggleCronForm(); showToast('Job created ✓'); await loadCrons(); @@ -344,12 +405,45 @@ async function openSkill(name, el) { $('previewBadge').textContent = 'skill'; $('previewBadge').className = 'preview-badge md'; showPreview('md'); - $('previewMd').innerHTML = renderMd(data.content || '(no content)'); + let html = renderMd(data.content || '(no content)'); + // Render linked files section if present + const lf = data.linked_files || {}; + const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0); + if (categories.length) { + html += '
Linked Files
'; + for (const [cat, files] of categories) { + html += `

${esc(cat)}

`; + for (const f of files) { + html += `${esc(f)}`; + } + html += '
'; + } + html += '
'; + } + $('previewMd').innerHTML = html; $('previewArea').classList.add('visible'); $('fileTree').style.display = 'none'; } catch(e) { setStatus('Could not load skill: ' + e.message); } } +async function openSkillFile(skillName, filePath) { + try { + const data = await api(`/api/skills/content?name=${encodeURIComponent(skillName)}&file=${encodeURIComponent(filePath)}`); + $('previewPathText').textContent = skillName + ' / ' + filePath; + $('previewBadge').textContent = filePath.split('.').pop() || 'file'; + $('previewBadge').className = 'preview-badge code'; + const ext = filePath.split('.').pop() || ''; + if (['md','markdown'].includes(ext)) { + showPreview('md'); + $('previewMd').innerHTML = renderMd(data.content || ''); + } else { + showPreview('code'); + $('previewCode').textContent = data.content || ''; + requestAnimationFrame(() => highlightCode()); + } + } catch(e) { setStatus('Could not load file: ' + e.message); } +} + // ── Skill create/edit form ── let _editingSkillName = null; diff --git a/static/style.css b/static/style.css index e5c39ed..642347b 100644 --- a/static/style.css +++ b/static/style.css @@ -562,6 +562,26 @@ body.resizing{user-select:none;cursor:col-resize;} /* Show more button inside tool card result */ .tool-card-more{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:3px 0 0;opacity:.7;display:block;} .tool-card-more:hover{opacity:1;} +/* Subagent cards: indented with accent border */ +.tool-card-subagent{border-left:2px solid rgba(124,185,255,.3);margin-left:8px;} +/* Token usage badge below assistant messages */ +.msg-usage{font-size:11px;color:var(--muted);opacity:.6;margin-top:2px;padding-left:42px;} +.msg-usage:hover{opacity:1;} +/* Skill picker (cron create form) */ +.skill-picker-wrap{position:relative;} +.skill-picker-dropdown{position:absolute;left:0;right:0;top:100%;background:var(--sidebar);border:1px solid var(--border2);border-radius:6px;z-index:10;max-height:180px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.3);} +.skill-opt{padding:6px 10px;cursor:pointer;font-size:12px;color:var(--muted);transition:background .1s;} +.skill-opt:hover{background:rgba(255,255,255,.08);color:var(--text);} +.skill-picker-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;} +.skill-tag{background:rgba(124,185,255,.12);border:1px solid rgba(124,185,255,.25);border-radius:12px;padding:2px 8px;font-size:11px;color:var(--blue);display:flex;align-items:center;gap:4px;} +.remove-tag{cursor:pointer;opacity:.6;font-size:13px;line-height:1;} +.remove-tag:hover{opacity:1;color:var(--accent);} +/* Skill linked files section */ +.skill-linked-files{margin-top:16px;border-top:1px solid var(--border);padding-top:12px;} +.skill-linked-section{margin-bottom:8px;} +.skill-linked-section h4{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:4px;} +.skill-linked-file{display:block;font-size:12px;padding:3px 6px;border-radius:4px;cursor:pointer;color:var(--blue);text-decoration:none;} +.skill-linked-file:hover{background:rgba(255,255,255,.06);} .tool-card-row{margin:0;padding:1px 0;} .tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;} .tool-card:hover{border-color:rgba(255,255,255,.12);} diff --git a/static/ui.js b/static/ui.js index 0650985..8124383 100644 --- a/static/ui.js +++ b/static/ui.js @@ -82,6 +82,8 @@ let _scrollPinned=true; _scrollPinned=nearBottom; }); })(); +function _fmtTokens(n){if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);} + function scrollIfPinned(){ if(!_scrollPinned) return; const el=$('messages'); @@ -486,6 +488,21 @@ function renderMessages(){ else inner.appendChild(frag); } } + // Render per-turn usage badge on the last assistant message (if usage data exists) + if(S.session&&(S.session.input_tokens||S.session.output_tokens)){ + const lastAssist=inner.querySelector('.msg-row:last-child'); + if(lastAssist&&!lastAssist.querySelector('.msg-usage')){ + const usage=document.createElement('div'); + usage.className='msg-usage'; + const inTok=S.session.input_tokens||0; + const outTok=S.session.output_tokens||0; + const cost=S.session.estimated_cost; + let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`; + if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; + usage.textContent=text; + lastAssist.appendChild(usage); + } + } scrollToBottom(); // Apply syntax highlighting after DOM is built requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();}); @@ -499,7 +516,8 @@ function toolIcon(name){ const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍', web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧', memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖', - send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️'}; + send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️', + subagent_progress:'🔀'}; return icons[name]||'🔧'; } @@ -520,13 +538,22 @@ function buildToolCard(tc){ } const hasMore=tc.snippet&&tc.snippet.length>displaySnippet.length; const runIndicator=tc.done===false?'':''; + const isSubagent=tc.name==='subagent_progress'; + const isDelegation=tc.name==='delegate_task'; + const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':''); + // Clean up subagent preview: strip leading 🔀 emoji since the icon already shows it + let displayName=tc.name; + if(isSubagent) displayName='Subagent'; + if(isDelegation) displayName='Delegate task'; + let previewText=tc.preview||displaySnippet||''; + if(isSubagent) previewText=previewText.replace(/^🔀\s*/,''); row.innerHTML=` -
+
${runIndicator} ${icon} - ${esc(tc.name)} - ${esc(tc.preview||displaySnippet||'')} + ${esc(displayName)} + ${esc(previewText)} ${hasDetail?'':''}
${hasDetail?`
diff --git a/tests/test_sprint24.py b/tests/test_sprint24.py new file mode 100644 index 0000000..312d71d --- /dev/null +++ b/tests/test_sprint24.py @@ -0,0 +1,121 @@ +""" +Sprint 24 Tests: agentic transparency — token/cost display, session usage fields, +subagent card names, skill picker in cron, skill linked files. +""" +import json, urllib.error, urllib.request + +BASE = "http://127.0.0.1:8788" + + +def get(path): + with urllib.request.urlopen(BASE + path, timeout=10) as r: + return json.loads(r.read()), r.status + + +def post(path, body=None): + data = json.dumps(body or {}).encode() + req = urllib.request.Request(BASE + path, data=data, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()), r.status + except urllib.error.HTTPError as e: + return json.loads(e.read()), e.code + + +def make_session(created_list): + d, _ = post("/api/session/new", {}) + sid = d["session"]["session_id"] + created_list.append(sid) + return sid, d["session"] + + +# ── Session usage fields ───────────────────────────────────────────────── + +def test_new_session_has_usage_fields(): + """New session should include input_tokens, output_tokens, estimated_cost.""" + created = [] + try: + sid, sess = make_session(created) + post("/api/session/rename", {"session_id": sid, "title": "Usage Test"}) + d, status = get(f"/api/session?session_id={sid}") + assert status == 200 + assert "input_tokens" in d["session"] + assert "output_tokens" in d["session"] + assert "estimated_cost" in d["session"] + assert d["session"]["input_tokens"] == 0 + assert d["session"]["output_tokens"] == 0 + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +def test_session_compact_has_usage_fields(): + """Session list should include usage fields in compact form.""" + created = [] + try: + sid, _ = make_session(created) + post("/api/session/rename", {"session_id": sid, "title": "Compact Usage"}) + d, status = get("/api/sessions") + assert status == 200 + match = [s for s in d["sessions"] if s["session_id"] == sid] + assert len(match) == 1 + assert "input_tokens" in match[0] + assert "output_tokens" in match[0] + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +def test_session_usage_defaults_zero(): + """New session usage fields should default to 0/None.""" + created = [] + try: + sid, sess = make_session(created) + assert sess.get("input_tokens", 0) == 0 + assert sess.get("output_tokens", 0) == 0 + finally: + for s in created: + post("/api/session/delete", {"session_id": s}) + + +# ── Skills content linked_files ────────────────────────────────────────── + +def test_skills_content_requires_name(): + """GET /api/skills/content without name should return 400 or 500 (if skills module unavailable).""" + try: + d, status = get("/api/skills/content?file=test.md") + assert status == 400 + except urllib.error.HTTPError as e: + # 500 is acceptable if the skills_tool import fails in test env + assert e.code in (400, 500) + + +def test_skills_content_has_linked_files_key(): + """GET /api/skills/content should return a linked_files key.""" + try: + d, status = get("/api/skills") + if not d.get("skills"): + return # no skills in test env + name = d["skills"][0]["name"] + d2, status2 = get(f"/api/skills/content?name={name}") + assert status2 == 200 + assert "linked_files" in d2 + except urllib.error.HTTPError: + pass # skills may not work in test env + + +# ── Tool call integrity ────────────────────────────────────────────────── + +def test_tool_calls_have_real_names(): + """Tool calls in session JSON should not have unresolved 'tool' name.""" + created = [] + try: + sid, _ = make_session(created) + d, status = get(f"/api/session?session_id={sid}") + assert status == 200 + for tc in d["session"].get("tool_calls", []): + assert tc.get("name") != "tool", f"Unresolved name: {tc}" + finally: + for s in created: + post("/api/session/delete", {"session_id": s})