Release XP (v0.51.660): durable tool output/diff body on cold reload (#4927, completes #4925)
This commit is contained in:
4 files changed
+123
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+18
-3
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
Reference in new issue
Block a user