diff --git a/CHANGELOG.md b/CHANGELOG.md index 42f930a7..10a02c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.660] — 2026-06-25 — Release XP (terminal output and diffs survive a reload) + +### Fixed + +- **Terminal command output and patch/edit diffs no longer intermittently vanish after a reload in the transparent activity stream.** A settled tool card renders its output/diff from a `snippet`, but on a cold or paginated reload the card could rebuild from the raw assistant message envelope — which carries the command but not the result — and the join to the separate result message would sometimes miss (id mismatch, recovery-rebuilt turn), leaving the body empty. The rebuild now falls back to the durable per-tool snippet from the persisted session summary across all tool formats (OpenAI, Anthropic `tool_use`, and partial), and the loaded summary is now carried onto the session object so that fallback source is populated on cold load. The live in-memory enrichment becomes a fast path rather than the only path that shows the body. (#4927, completes #4925) + ## [v0.51.659] — 2026-06-25 — Release XO (one-click extension install works with no setup) ### Fixed diff --git a/static/sessions.js b/static/sessions.js index 25fc5a33..6de1fe0a 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -2200,6 +2200,13 @@ function _syncToolCallsForLoadedMessages(messages, sessionToolCalls){ // During active streaming, skip — clearing S.toolCalls would lose Activity // and the renderMessages fallback is blocked by S.busy=true. if(S.busy||S.activeStreamId) return; + // Persist the loaded compact tool summary onto S.session so the renderMessages + // derived rebuild can use it as a durable per-tid snippet fallback on cold + // load (#4927). loadSession keeps the messages=0 session object (tool_calls + // []), and the messages=1 summary arrives only as this argument — without + // copying it across, the fallback source is empty exactly on the cold-load + // path it's meant to repair. + if(S.session&&Array.isArray(sessionToolCalls)) S.session.tool_calls=sessionToolCalls.map(tc=>({...tc})); const hasMessageToolMetadata=msgs.some(m=>{ if(!m) return false; const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; diff --git a/static/ui.js b/static/ui.js index 744f1584..58635ada 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11808,6 +11808,21 @@ function renderMessages(options){ // Without this step CLI-origin sessions reload with empty tool cards. const resultsByTid={}; const fallbackToolSources=[]; + // Durable fallback: the persisted compact summary (session.tool_calls, built + // by _extract_tool_calls_from_messages) carries a bounded result `snippet` + // keyed by tid. On a cold/paginated load where the role:tool result-message + // join below misses (id mismatch, recovery-rebuilt turn), use this so the + // terminal output / diff body still renders instead of vanishing (#4927). + const persistedSnippetByTid={}; + try{ + const persisted=(S.session&&Array.isArray(S.session.tool_calls))?S.session.tool_calls:[]; + persisted.forEach(tc=>{ + if(!tc||typeof tc!=='object') return; + const ptid=tc.tid||tc.id||tc.tool_call_id||tc.call_id||''; + const psnip=tc.snippet||tc.result||tc.output||tc.preview||''; + if(ptid&&psnip&&!persistedSnippetByTid[ptid]) persistedSnippetByTid[ptid]=String(psnip); + }); + }catch(e){} S.messages.forEach((m,rawIdx)=>{ if(!m) return; // OpenAI / Hermes CLI format: role=tool with tool_call_id @@ -11873,7 +11888,7 @@ function renderMessages(options){ try{ args=JSON.parse(fn.arguments||'{}'); }catch(e){} const tid=tc.id||tc.call_id||''; const patchSnippet=_cliPatchSnippetFromArgs(name,args); - const resultSnippet=resultsByTid[tid]||''; + const resultSnippet=resultsByTid[tid]||persistedSnippetByTid[tid]||''; let argsSnap=_toolArgsSnapshot(args); derived.push(copyLiveToolMetadata({ name, @@ -11900,7 +11915,7 @@ function renderMessages(options){ } const tid=tc.tid||tc.id||tc.tool_call_id||tc.call_id||''; const patchSnippet=_cliPatchSnippetFromArgs(name,args); - const resultSnippet=resultsByTid[tid]||tc.snippet||tc.preview||''; + const resultSnippet=resultsByTid[tid]||tc.snippet||tc.preview||persistedSnippetByTid[tid]||''; const argsSnap=_toolArgsSnapshot(args); derived.push(copyLiveToolMetadata({ name, @@ -11920,7 +11935,7 @@ function renderMessages(options){ const args=p.input||{}; const tid=p.id||''; const patchSnippet=_cliPatchSnippetFromArgs(name,args); - const resultSnippet=resultsByTid[tid]||''; + const resultSnippet=resultsByTid[tid]||persistedSnippetByTid[tid]||''; const argsSnap=_toolArgsSnapshot(args); derived.push(copyLiveToolMetadata({ name, diff --git a/tests/test_issue4927_durable_tool_snippet.py b/tests/test_issue4927_durable_tool_snippet.py new file mode 100644 index 00000000..a787813d --- /dev/null +++ b/tests/test_issue4927_durable_tool_snippet.py @@ -0,0 +1,92 @@ +"""Regression coverage for #4927 — terminal output / patch diffs must survive a +cold reload in the transparent activity stream. + +A settled tool row renders its output/diff from `snippet`. There are two rebuild +paths: the persisted compact summary (`session.tool_calls`, which HAS a bounded +`snippet`) and the raw-assistant-envelope `derived` rebuild (which joins the +result by tool id via `resultsByTid`). On a cold/paginated load the +`resultsByTid` join can miss (id mismatch, recovery-rebuilt turn) — and #4622's +live enrichment only helps when `S.toolCalls` still matches (empty on cold +load). So the derived build must fall back to the persisted `session.tool_calls` +snippet by tid, making the durable record the reliable source. + +This is a source-structure guard: the derived rebuild is inlined in +`renderMessages`, so we assert the fallback wiring is present rather than +extracting a standalone function. +""" +from __future__ import annotations + +import re +from pathlib import Path + +UI_JS = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8") +SESSIONS_JS = (Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _slice_derived_rebuild() -> str: + """Return the renderMessages fallback-rebuild region (resultsByTid block).""" + start = UI_JS.index("const resultsByTid={};") + # The region runs through the _partial_tool_calls derived push; bound it + # generously so both derived-push sites are included. + return UI_JS[start:start + 6000] + + +def test_persisted_snippet_lookup_is_built_from_session_tool_calls(): + """A tid->snippet lookup must be built from S.session.tool_calls.""" + region = _slice_derived_rebuild() + assert "persistedSnippetByTid" in region, "no persisted-snippet fallback lookup" + # It must be sourced from the durable persisted summary. + assert re.search(r"S\.session\s*&&\s*Array\.isArray\(S\.session\.tool_calls\)", region), \ + "persistedSnippetByTid must be built from S.session.tool_calls" + # It must key by tid and capture the snippet. + assert re.search(r"persistedSnippetByTid\[\s*ptid\s*\]\s*=", region), \ + "persisted lookup must be keyed by tid" + + +def test_derived_result_snippet_falls_back_to_persisted(): + """The derived OpenAI-format build must use persistedSnippetByTid as a + fallback when the live result-message join (resultsByTid) misses.""" + region = _slice_derived_rebuild() + # The primary OpenAI-format derived push. + assert "resultsByTid[tid]||persistedSnippetByTid[tid]" in region, ( + "derived resultSnippet must fall back to the persisted snippet by tid " + "(#4927) so cold-load tool output/diffs don't vanish" + ) + + +def test_partial_tool_calls_path_also_falls_back(): + """The _partial_tool_calls derived path must also use the persisted fallback.""" + region = _slice_derived_rebuild() + assert "resultsByTid[tid]||tc.snippet||tc.preview||persistedSnippetByTid[tid]" in region, ( + "the partial-tool-calls derived path must also fall back to the " + "persisted snippet (#4927)" + ) + + +def test_all_derived_result_snippet_reads_have_fallback(): + """Every derived-push result-snippet READ (OpenAI top-level, Anthropic + tool_use content-array, and partial) must include the persisted fallback — + a missed branch (e.g. the Anthropic content-array push) would still + cold-reload with an empty body (#4927 gate).""" + region = _slice_derived_rebuild() + # Find every `const resultSnippet=resultsByTid[tid]...` read and assert each + # one chains to persistedSnippetByTid. + reads = re.findall(r"const resultSnippet=resultsByTid\[tid\][^;]*;", region) + assert reads, "no resultSnippet reads found in the derived rebuild" + for r in reads: + assert "persistedSnippetByTid[tid]" in r, ( + f"derived resultSnippet read missing the persisted fallback (#4927): {r}" + ) + + +def test_loaded_session_tool_calls_persisted_onto_session(): + """#4927 gate: the cold-load path must persist the messages=1 compact tool + summary onto S.session.tool_calls, or the renderMessages fallback source is + empty on exactly the cold-load path it repairs (loadSession keeps the + messages=0 object whose tool_calls is []).""" + start = SESSIONS_JS.index("function _syncToolCallsForLoadedMessages(") + region = SESSIONS_JS[start:start + 1500] + assert "S.session.tool_calls=sessionToolCalls" in region.replace(" ", ""), ( + "_syncToolCallsForLoadedMessages must copy the loaded sessionToolCalls " + "onto S.session.tool_calls so the derived-rebuild fallback has a source" + )