Skip to content
Closed
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
6 changes: 4 additions & 2 deletions api/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,9 @@ def _redact_value(v, *, _enabled: bool | None = None):


def redact_session_data(session_dict: dict) -> dict:
"""Redact credentials from message content and tool_call data before API response.
"""Redact credentials from message content, tool data, and session sidecars.

Applies to: messages[], tool_calls[], and title.
Applies to: messages[], tool_calls[], todo_state, and title.
The underlying session file is not modified; redaction is response-layer only.

Reads the ``api_redact_enabled`` setting ONCE for the entire response and
Expand All @@ -387,6 +387,8 @@ def redact_session_data(session_dict: dict) -> dict:
result['messages'] = _redact_value(result['messages'], _enabled=_enabled)
if 'tool_calls' in result:
result['tool_calls'] = _redact_value(result['tool_calls'], _enabled=_enabled)
if 'todo_state' in result:
result['todo_state'] = _redact_value(result['todo_state'], _enabled=_enabled)
return result


Expand Down
10 changes: 10 additions & 0 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2854,6 +2854,7 @@ def _keep_latest_messaging_session_per_source(
read_run_events,
stale_interrupted_event,
)
from api.todo_state import attach_todo_state
from api.providers import get_providers, get_provider_quota, get_provider_cost_history, set_provider_key, remove_provider_key
from api.onboarding import (
apply_onboarding_setup,
Expand Down Expand Up @@ -4714,6 +4715,14 @@ def handle_get(handler, parsed) -> bool:
journal,
active=bool(getattr(s, "active_stream_id", None)),
)
# Cold-load: derive the latest settled todo snapshot from the full
# merged transcript, not the truncated display window. This keeps
# the Todos panel correct after refresh even when the latest todo
# tool result is outside msg_limit, and treats an explicit empty
# todo list as the current state instead of falling through to an
# older non-empty write.
if load_messages and _all_msgs:
attach_todo_state(raw, _all_msgs)
if _merged_last_message_at:
raw["last_message_at"] = max(
float(raw.get("last_message_at") or 0),
Expand Down Expand Up @@ -4789,6 +4798,7 @@ def handle_get(handler, parsed) -> bool:
"messages": msgs,
"tool_calls": [],
}
attach_todo_state(sess, msgs)
sess = _merge_cli_sidebar_metadata(sess, cli_meta)
return j(handler, {"session": redact_session_data(sess)})
return bad(handler, "Session not found", 404)
Expand Down
122 changes: 122 additions & 0 deletions api/todo_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Derive settled todo snapshots for session GET responses.

The browser's Todos panel currently reconstructs state by reverse-scanning
loaded tool messages. That works only when the latest todo tool result is inside
the returned message window. This helper gives ``/api/session`` a compact,
explicit ``todo_state`` sidecar derived from the full settled transcript.

The detector intentionally mirrors ``run_agent.AIAgent._hydrate_todo_store``:
walk messages newest-first and use the first tool message whose JSON content has
a ``todos`` list. Empty ``todos`` is a valid snapshot so a cleared task list does
not fall through to an older non-empty write.
"""

from __future__ import annotations

import json
import logging
from typing import Any, Iterable, Optional, Sequence

logger = logging.getLogger(__name__)

VERSION = 1
PAYLOAD_KEY = "todo_state"


def _normalize_snapshot(data: Any) -> Optional[dict]:
"""Return the canonical todo snapshot shape, or ``None`` for non-todo data."""
if not isinstance(data, dict):
return None
todos = data.get("todos")
if not isinstance(todos, list):
return None
summary = data.get("summary")
if not isinstance(summary, dict):
summary = {}
return {
"todos": todos,
"summary": summary,
"version": VERSION,
}


def parse_todo_tool_result(function_result: Any) -> Optional[dict]:
"""Parse a todo tool result JSON string or pre-parsed dict into a snapshot."""
data: Any = function_result
if isinstance(function_result, str):
try:
data = json.loads(function_result)
except (TypeError, ValueError):
return None
return _normalize_snapshot(data)


def _message_ts_float(ts_raw: Any) -> float:
"""Coerce a message ``timestamp`` field to a positive float, or 0.0."""
try:
return float(ts_raw) if ts_raw is not None else 0.0
except (TypeError, ValueError):
return 0.0


def _max_timestamp_through(messages: Sequence[Any], upto_idx: int) -> float:
"""Return the largest valid timestamp at or before ``upto_idx``."""
best = 0.0
end = min(upto_idx, len(messages) - 1)
for i in range(end, -1, -1):
msg = messages[i]
if not isinstance(msg, dict):
continue
best = max(best, _message_ts_float(msg.get("timestamp")))
return best


def derive_todo_state(messages: Optional[Iterable[dict]]) -> Optional[dict]:
"""Derive the latest settled todo snapshot from conversation history.

Returns ``None`` when the session has no todo writes. Malformed or
non-string tool contents are skipped so unrelated tool results never break
session loading.
"""
if not messages:
return None
if not isinstance(messages, (list, tuple)):
messages = list(messages)

for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if not isinstance(msg, dict) or msg.get("role") != "tool":
continue
content = msg.get("content", "")
if not isinstance(content, str) or '"todos"' not in content:
continue
snapshot = parse_todo_tool_result(content)
if snapshot is None:
continue

ts_val = _message_ts_float(msg.get("timestamp"))
if ts_val <= 0:
ts_val = _max_timestamp_through(messages, idx)
if ts_val > 0:
snapshot["ts"] = ts_val
return snapshot
return None


def attach_todo_state(payload: dict, messages: Optional[Iterable[dict]]) -> bool:
"""Attach ``todo_state`` to a session payload when one can be derived.

Mutates ``payload`` in place. Errors are swallowed deliberately: a malformed
historical tool message must not make ``/api/session`` fail.
"""
if not messages:
return False
try:
snapshot = derive_todo_state(messages)
if snapshot is None:
return False
payload[PAYLOAD_KEY] = snapshot
return True
except Exception:
logger.debug("todo_state attach failed", exc_info=True)
return False
49 changes: 31 additions & 18 deletions static/panels.js
Original file line number Diff line number Diff line change
Expand Up @@ -2650,37 +2650,50 @@ async function loadKanbanTask(taskId){
function loadTodos() {
const panel = $('todoPanel');
if (!panel) return;
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
// Parse the most recent todo state from message history
let todos = [];
for (let i = sourceMessages.length - 1; i >= 0; i--) {
const m = sourceMessages[i];
if (m && m.role === 'tool') {
try {
const d = JSON.parse(typeof m.content === 'string' ? m.content : JSON.stringify(m.content));
if (d && Array.isArray(d.todos) && d.todos.length) {
todos = d.todos;
break;
}
} catch(e) {}
}

const sessionTodoState = S.session && S.session.todo_state;
let todos;
if (sessionTodoState && Array.isArray(sessionTodoState.todos)) {
todos = sessionTodoState.todos;
} else {
todos = _legacyTodosFromMessages();
}

if (!todos.length) {
panel.innerHTML = `<div style="color:var(--muted);font-size:12px;padding:4px 0">${esc(t('todos_no_active'))}</div>`;
return;
}
const statusIcon = {pending:li('square',14), in_progress:li('loader',14), completed:li('check',14), cancelled:li('x',14)};
const statusColor = {pending:'var(--muted)', in_progress:'var(--blue)', completed:'rgba(100,200,100,.8)', cancelled:'rgba(200,100,100,.5)'};
panel.innerHTML = todos.map(t => `
panel.innerHTML = todos.map(todo => `
<div style="display:flex;align-items:flex-start;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);">
<span style="font-size:14px;display:inline-flex;align-items:center;flex-shrink:0;margin-top:1px;color:${statusColor[t.status]||'var(--muted)'}">${statusIcon[t.status]||li('square',14)}</span>
<span style="font-size:14px;display:inline-flex;align-items:center;flex-shrink:0;margin-top:1px;color:${statusColor[todo.status]||'var(--muted)'}">${statusIcon[todo.status]||li('square',14)}</span>
<div style="flex:1;min-width:0">
<div style="font-size:13px;color:${t.status==='completed'?'var(--muted)':t.status==='in_progress'?'var(--text)':'var(--text)'};${t.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(t.content)}</div>
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(t.id)} · ${esc(t.status)}</div>
<div style="font-size:13px;color:${todo.status==='completed'?'var(--muted)':todo.status==='in_progress'?'var(--text)':'var(--text)'};${todo.status==='completed'?'text-decoration:line-through;opacity:.5':''};line-height:1.4">${esc(todo.content)}</div>
<div style="font-size:10px;color:var(--muted);margin-top:2px;opacity:.6">${esc(todo.id)} · ${esc(todo.status)}</div>
</div>
</div>`).join('');
}

function _legacyTodosFromMessages() {
const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;
if (!Array.isArray(sourceMessages)) return [];
for (let i = sourceMessages.length - 1; i >= 0; i--) {
const m = sourceMessages[i];
if (!m || m.role !== 'tool') continue;
let content = m.content;
if (typeof content !== 'string') {
try { content = JSON.stringify(content); } catch (_) { continue; }
}
if (!content || content.indexOf('"todos"') < 0) continue;
try {
const d = JSON.parse(content);
if (d && Array.isArray(d.todos) && d.todos.length) return d.todos;
} catch (_) {}
}
return [];
}

// ────────────────────────────────────────────────────────────────────────────
// Kanban: multi-board switcher + create/rename/archive modal
// ────────────────────────────────────────────────────────────────────────────
Expand Down
2 changes: 2 additions & 0 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,8 @@ async function _ensureMessagesLoaded(sid) {
S.messages = msgs;
if(S.session&&S.session.session_id===sid){
S.session.message_count=Number(data.session.message_count || msgs.length);
if(Object.prototype.hasOwnProperty.call(data.session,'todo_state')) S.session.todo_state=data.session.todo_state;
else delete S.session.todo_state;
S.lastUsage={...(data.session.last_usage||S.lastUsage||{})};
_setSessionViewedCount(sid, Number(S.session.message_count || msgs.length));
}
Expand Down
20 changes: 20 additions & 0 deletions tests/test_security_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,26 @@ def test_redact_session_data_messages():
assert result["session_id"] == "abc123"
assert result["messages"][1]["content"] == "sure"

def test_redact_session_data_todo_state_sidecar():
"""redact_session_data masks credentials in derived todo_state sidecars."""
from api.helpers import redact_session_data
session = {
"session_id": "todo-redact",
"messages": [],
"tool_calls": [],
"todo_state": {
"todos": [
{"id": "1", "content": f"rotate api key {_FAKE_SK_KEY}", "status": "pending"},
],
"summary": {"total": 1, "pending": 1},
"version": 1,
},
}
result = redact_session_data(session)
dump = json.dumps(result)
_assert_no_plaintext_credentials(dump, "todo_state redaction")
assert result["todo_state"]["todos"][0]["status"] == "pending"


def test_redact_session_data_multiple_cred_types():
"""redact_session_data handles sk-, ghp_, hf_, and AKIA keys."""
Expand Down
47 changes: 47 additions & 0 deletions tests/test_session_todo_state_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import ast
from pathlib import Path


ROUTES_PY = Path(__file__).parent.parent / "api" / "routes.py"


def _attach_todo_state_calls() -> list[ast.Call]:
tree = ast.parse(ROUTES_PY.read_text(encoding="utf-8"))
calls: list[ast.Call] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
if isinstance(func, ast.Name) and func.id == "attach_todo_state":
calls.append(node)
return calls


def test_routes_imports_attach_todo_state():
tree = ast.parse(ROUTES_PY.read_text(encoding="utf-8"))

assert any(
isinstance(node, ast.ImportFrom)
and node.module == "api.todo_state"
and any(alias.name == "attach_todo_state" for alias in node.names)
for node in ast.walk(tree)
)


def test_routes_attach_todo_state_from_webui_and_cli_session_paths():
calls = _attach_todo_state_calls()

assert len(calls) >= 2
arg_names = []
for call in calls[:2]:
assert len(call.args) == 2
assert not call.keywords
assert isinstance(call.args[0], ast.Name)
assert isinstance(call.args[1], ast.Name)
assert call.args[0].id != call.args[1].id
arg_names.append((call.args[0].id, call.args[1].id))

assert ("raw", "_all_msgs") in arg_names
assert ("sess", "msgs") in arg_names
35 changes: 35 additions & 0 deletions tests/test_todo_panel_cold_load_static.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from pathlib import Path


REPO_ROOT = Path(__file__).parent.parent


def test_ensure_messages_loaded_copies_session_todo_state_sidecar():
src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")

assert "Object.prototype.hasOwnProperty.call(data.session,'todo_state')" in src
assert "S.session.todo_state=data.session.todo_state" in src
assert "else delete S.session.todo_state" in src


def test_load_todos_prefers_session_todo_state_before_legacy_scan():
src = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
start = src.find("function loadTodos()")
end = src.find("function _legacyTodosFromMessages()")

assert start != -1
assert end != -1
load_todos = src[start:end]

assert "const sessionTodoState = S.session && S.session.todo_state;" in load_todos
assert "sessionTodoState && Array.isArray(sessionTodoState.todos)" in load_todos
assert "todos = sessionTodoState.todos;" in load_todos
assert "todos = _legacyTodosFromMessages();" in load_todos
assert load_todos.find("todos = sessionTodoState.todos;") < load_todos.find("todos = _legacyTodosFromMessages();")


def test_legacy_todos_fallback_still_uses_raw_session_messages():
src = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")

assert "function _legacyTodosFromMessages()" in src
assert "const sourceMessages = (S.session && Array.isArray(S.session.messages) && S.session.messages.length) ? S.session.messages : S.messages;" in src
Loading
Loading