diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd68f5b..5c675ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ ### Fixed +- **Workspace → Todos now updates live while the tab is open.** The existing todo refresh scheduler now also repaints the right-panel Workspace → Todos tab when that optional tab is enabled and currently visible, while preserving the left sidebar Todos refresh path and avoiding work when Files or Artifacts is active. + - **Settled assistant turns with interleaved text and tool calls now keep their original order, and no post-tool text is dropped.** When an assistant turn mixed prose with tool calls (text → tool → more text), the settled/reloaded transcript could lose the chronological ordering or silently drop post-tool text/thinking from a non-final assistant message. The Stable Assistant Turn Anchor now promotes a settled turn's mixed `content[]` into ordered scene rows (prose, tool, and thinking rows in sequence) instead of only recovering the raw fallback, and the backend hydration path mirrors the same scene model so a cold reload reconstructs an identical transcript. Only the turn-final assistant message's post-last-tool text is treated as the final answer; earlier assistant messages keep their post-tool content as activity rows. Tool-row de-duplication is conservative (it only merges rows it can positively confirm are the same call), biasing toward an extra visible card over ever silently losing one. Thanks @franksong2702. (#4958) - **Manual `/compress` now sticks across reloads and restarts instead of springing back to the old context size.** A manual compression shrinks the model-facing context but the old transcript could return through two paths: (1) append-only `state.db` reconciliation re-appending pre-compression rows, and (2) startup `.bak` recovery treating the intentional shrink as data loss. `/compress` now persists a truncation watermark/boundary (so reconciliation stops replaying pre-compression rows), refreshes the post-compression token metric, and drops the now-stale backup. Crucially, the `.bak` safeguard is only suppressed for the *pre-compression* backup whose restore would undo the shrink (detected by content — its model-facing context is still the larger uncompressed one) — a backup written *after* the compression that captures genuine post-compression data loss is **still recovered**, so manual compression can never permanently disable crash-recovery for that session. Thanks @hyl-ailab. (#4986, fixes #4836) diff --git a/api/streaming.py b/api/streaming.py index 683d4887..c18159f1 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -45,7 +45,7 @@ from api.helpers import redact_session_data, _redact_text from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor from api.metering import meter from api.run_journal import RunJournalWriter -from api.todo_state import emit_todo_state +from api.todo_state import attach_todo_state, emit_todo_state from api.turn_journal import append_turn_journal_event_for_stream from api.usage import prompt_cache_hit_percent from api.models import ( @@ -69,6 +69,7 @@ def _session_payload_with_full_messages(session, *, tool_calls=None): 'messages': messages, 'message_count': len(messages), } + attach_todo_state(raw, messages) if tool_calls is not None: raw['tool_calls'] = tool_calls return raw diff --git a/static/ui.js b/static/ui.js index 97f189ca..2d8cc95f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7059,8 +7059,9 @@ function clearInflightState(sid){ // and as the hash that compares "rendered vs current" snapshots. // // 2. scheduleTodosRefresh() — coalesces multiple `todo_state` events that -// land in the same animation frame into a single loadTodos() call. -// Skips work entirely when the panel is not active. +// land in the same animation frame into a single refresh pass. It keeps +// the left sidebar Todos behavior unchanged, and also lets the workspace +// Todos tab repaint when that tab is enabled and currently visible. // // 3. _hydrateTodosFromSession(session) — applies cold-load todo_state // from the session GET payload, or clears the panel when neither a @@ -7103,12 +7104,14 @@ function scheduleTodosRefresh(){ if(_todosRenderRafId) return; if(typeof requestAnimationFrame!=='function'){ if(typeof loadTodos==='function') loadTodos(); + if(typeof _refreshWorkspacePanelTodos==='function') _refreshWorkspacePanelTodos(); return; } _todosRenderRafId=requestAnimationFrame(()=>{ _todosRenderRafId=0; - if(!_todosPanelIsActive()) return; - if(typeof loadTodos==='function') loadTodos(); + const sidebarActive=_todosPanelIsActive(); + if(sidebarActive&&typeof loadTodos==='function') loadTodos(); + if(typeof _refreshWorkspacePanelTodos==='function') _refreshWorkspacePanelTodos(); }); } @@ -7116,6 +7119,7 @@ function _resetTodosRenderCache(){ // Clear after every cross-session navigation so the next render is // never short-circuited against a hash from a different session. _todosLastRenderedHash=null; + if(typeof _resetWorkspaceTodosRenderCache==='function') _resetWorkspaceTodosRenderCache(); } function _hydrateTodosFromSession(session){ @@ -7196,6 +7200,7 @@ function _hydrateTodosFromSession(session){ S.todoStateMeta=null; } _resetTodosRenderCache(); + if(typeof scheduleTodosRefresh==='function') scheduleTodosRefresh(); } function snapshotLiveTurnHtmlForSession(sid){ diff --git a/static/workspace.js b/static/workspace.js index f6c07879..0ddda0d7 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -289,6 +289,7 @@ async function authorizeWorkspaceEscapeNavigation(item){ let _workspacePanelActiveTab = 'files'; let _renderSessionArtifactsTimer = null; +let _workspaceTodosLastRenderedHash = null; function _setWorkspacePanelTabDataset(){ const panel = document.querySelector('.rightpanel'); @@ -303,6 +304,35 @@ function scheduleRenderSessionArtifacts(){ }, 100); } +function _workspaceTodosHash(items){ + if(!Array.isArray(items)) return ''; + let h=items.length+'|'; + for(let i=0;i { if(s === 'completed') return ''; if(s === 'in_progress') return ''; diff --git a/tests/test_streaming_done_payload_message_count.py b/tests/test_streaming_done_payload_message_count.py index f3c8ded4..64440c11 100644 --- a/tests/test_streaming_done_payload_message_count.py +++ b/tests/test_streaming_done_payload_message_count.py @@ -1,5 +1,6 @@ """Regression coverage for settled SSE payload message counts.""" +import json from pathlib import Path from types import SimpleNamespace @@ -35,6 +36,40 @@ def test_full_message_payload_overrides_stale_compact_message_count(): assert payload["message_count"] != session.compact()["message_count"] +def test_full_message_payload_includes_todo_state_snapshot(): + todo_result = { + "todos": [ + {"id": "todo-1", "content": "keep workspace todos visible", "status": "in_progress"}, + ], + "summary": { + "total": 1, + "pending": 0, + "in_progress": 1, + "completed": 0, + "cancelled": 0, + }, + } + session = _FakeSession( + session_id="todo-session", + messages=[ + {"role": "user", "content": "plan the fix", "timestamp": 100}, + { + "role": "tool", + "content": json.dumps(todo_result), + "timestamp": 101, + }, + {"role": "assistant", "content": "done", "timestamp": 102}, + ], + ) + + payload = _session_payload_with_full_messages(session, tool_calls=[]) + + assert payload["todo_state"]["todos"] == todo_result["todos"] + assert payload["todo_state"]["summary"] == todo_result["summary"] + assert payload["todo_state"]["version"] == 1 + assert payload["todo_state"]["ts"] == 101 + + def test_done_payload_uses_full_message_count_helper(): done_idx = STREAMING_SOURCE.index("put('done', _done_payload)") block_start = STREAMING_SOURCE.rfind("raw_session =", 0, done_idx) diff --git a/tests/test_todo_live_frontend_static.py b/tests/test_todo_live_frontend_static.py index aac69d05..a789fc6b 100644 --- a/tests/test_todo_live_frontend_static.py +++ b/tests/test_todo_live_frontend_static.py @@ -51,6 +51,103 @@ def test_hydrate_todos_from_session_reconciles_cold_and_inflight_snapshots(): assert "S.todos=inflight.todos" in block assert "S.todoStateMeta=null" in block assert "_resetTodosRenderCache();" in block + assert "scheduleTodosRefresh()" in block + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required for frontend scheduler behavior test") +def test_schedule_todos_refresh_fans_out_without_breaking_sidebar_path(tmp_path): + script = r''' +const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('let _todosLastRenderedHash=null;'); +const end = src.indexOf('function _resetTodosRenderCache()', start); +if (start < 0 || end < 0) throw new Error('todo scheduler block not found'); +const block = src.slice(start, end) + '\nthis.scheduleTodosRefresh = scheduleTodosRefresh;'; +function run(panelActive) { + const panel = {classList:{contains:(name)=> name === 'active' && panelActive}}; + const calls = {load:0, workspace:0, raf:0}; + const context = { + document:{getElementById:(id)=> id === 'panelTodos' ? panel : null}, + requestAnimationFrame:(cb)=>{ calls.raf++; cb(); return calls.raf; }, + loadTodos:()=>{ calls.load++; }, + _refreshWorkspacePanelTodos:()=>{ calls.workspace++; }, + }; + vm.createContext(context); + vm.runInContext(block, context); + context.scheduleTodosRefresh(); + return calls; +} +function assert(cond, msg){ if(!cond) throw new Error(msg); } +let active = run(true); +assert(active.raf === 1, 'scheduler must use RAF coalescing'); +assert(active.load === 1, 'active sidebar Todos must still call loadTodos'); +assert(active.workspace === 1, 'scheduler must fan out to workspace refresh helper'); +let inactive = run(false); +assert(inactive.load === 0, 'inactive sidebar Todos must not call loadTodos from RAF path'); +assert(inactive.workspace === 1, 'workspace helper remains responsible for workspace visibility gating'); +''' + script_path = tmp_path / "todo_scheduler_test.js" + script_path.write_text(script, encoding="utf-8") + result = subprocess.run( + ["node", str(script_path), str(REPO_ROOT / "static" / "ui.js")], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr or result.stdout + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is required for workspace todo gate behavior test") +def test_workspace_todos_refresh_gate_requires_enabled_visible_todos_tab(tmp_path): + script = r''' +const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function _workspaceTodosTabIsActive()'); +const end = src.indexOf("if(typeof document !== 'undefined')", start); +if (start < 0 || end < 0) throw new Error('workspace todos gate block not found'); +const block = src.slice(start, end) + '\nthis._refreshWorkspacePanelTodos = _refreshWorkspacePanelTodos;'; +function run({enabled=true, activeTab='todos', tabHidden=false, panelHidden=false}) { + const elements = { + workspaceTodosTab:{hidden:tabHidden}, + workspaceTodosPanel:{hidden:panelHidden}, + }; + const calls = {load:0}; + const context = { + window:{_workspaceTodosTab:enabled}, + document:{ + querySelector:(sel)=> sel === '.rightpanel' ? {dataset:{activeTab}} : null, + getElementById:(id)=> elements[id] || null, + }, + _loadWorkspacePanelTodos:()=>{ calls.load++; }, + }; + vm.createContext(context); + vm.runInContext(block, context); + context._refreshWorkspacePanelTodos(); + return calls.load; +} +function assert(cond, msg){ if(!cond) throw new Error(msg); } +assert(run({}) === 1, 'visible enabled workspace Todos tab must refresh'); +assert(run({activeTab:'files'}) === 0, 'Files tab must not refresh workspace Todos'); +assert(run({activeTab:'artifacts'}) === 0, 'Artifacts tab must not refresh workspace Todos'); +assert(run({enabled:false}) === 0, 'default-off workspace Todos setting must not refresh'); +assert(run({tabHidden:true}) === 0, 'hidden workspace Todos tab must not refresh'); +assert(run({panelHidden:true}) === 0, 'hidden workspace Todos panel must not refresh'); +''' + script_path = tmp_path / "workspace_todos_gate_test.js" + script_path.write_text(script, encoding="utf-8") + result = subprocess.run( + ["node", str(script_path), str(REPO_ROOT / "static" / "workspace.js")], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr or result.stdout @pytest.mark.skipif(shutil.which("node") is None, reason="node is required for frontend handler behavior test") diff --git a/tests/test_todo_panel_cold_load_static.py b/tests/test_todo_panel_cold_load_static.py index 3964713b..51b6b869 100644 --- a/tests/test_todo_panel_cold_load_static.py +++ b/tests/test_todo_panel_cold_load_static.py @@ -52,3 +52,19 @@ def test_workspace_todos_tab_prefers_live_sse_snapshot_before_cold_load_sidecar( "Workspace Todos must prefer the live S.todos snapshot so opening the tab " "after todo_state SSE updates does not render the stale cold-load sidecar" ) + + +def test_workspace_todos_render_uses_separate_hash_cache(): + src = (REPO_ROOT / "static" / "workspace.js").read_text(encoding="utf-8") + start = src.find("function _loadWorkspacePanelTodos()") + end = src.find("function _escHtml", start) + + assert "let _workspaceTodosLastRenderedHash = null;" in src + assert "function _resetWorkspaceTodosRenderCache()" in src + assert start != -1 + assert end != -1 + helper = src[start:end] + + assert "_workspaceTodosLastRenderedHash === '__empty__'" in helper + assert "_workspaceTodosHash(todos)" in helper + assert "if(hash === _workspaceTodosLastRenderedHash) return;" in helper