Redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 Squash-merges PR #587 by @aronprins (Aron Prins). Full credit to @aronprins for all feature and fix work. Transcript redesign: unified --msg-rail/--msg-max CSS variables, user turns as tinted cards, thinking cards as bordered panels, error card treatment, day-change separators, composer fade. Approval/clarify as composer flyouts: cards slide up from behind composer top, overflow:hidden + translateY clip prevents travel visibility, focus({preventScroll:true}). Streaming lifecycle: DOM order user→thinking→tool cards→response, no mid-stream jump. Live tool cards inserted before [data-live-assistant]. Persistence: reasoning attached before s.save(), _restore_reasoning_metadata on reload, role=tool rows preserved in S.messages, CLI-session tool-result fallback. Workspace panel FOUC fix: [data-workspace-panel] set at parse time. Docs: docs/ui-ux/index.html + two-stage-proposal.html. Maintainer additions (433b867): CHANGELOG v0.50.70, version badge, usage badge loop simplification. Reviewed and approved by @nesquena (independent review). 1361 tests passing.
115 lines
4.9 KiB
Python
115 lines
4.9 KiB
Python
"""
|
|
Regression tests for tool-card persistence on session reload.
|
|
|
|
The older loadSession() path rewrote message history on the client:
|
|
- dropped role='tool' rows
|
|
- dropped empty assistant rows even when they carried tool_calls
|
|
- then ignored session.tool_calls on reload
|
|
|
|
That broke both durable logging and page refresh for valid tool runs.
|
|
"""
|
|
import json
|
|
import pathlib
|
|
import subprocess
|
|
import textwrap
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
|
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
|
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_loadsession_preserves_tool_rows():
|
|
"""Reload must keep tool rows in S.messages so snippets can be reconstructed."""
|
|
assert "if (m.role === 'tool') continue;" not in SESSIONS_JS, (
|
|
"loadSession() must not drop role='tool' messages; renderMessages() hides them "
|
|
"visually, but it still needs them for snippet reconstruction"
|
|
)
|
|
|
|
|
|
def test_loadsession_uses_session_toolcalls_only_as_fallback():
|
|
"""Session summaries are the fallback, not the primary reload source."""
|
|
assert "if(!hasMessageToolMetadata&&data.session.tool_calls&&data.session.tool_calls.length)" in SESSIONS_JS
|
|
assert "S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}));" in SESSIONS_JS
|
|
assert "S.toolCalls=[];" in SESSIONS_JS
|
|
|
|
|
|
def test_rendermessages_treats_openai_toolcall_assistants_as_visible():
|
|
"""OpenAI assistant rows with empty content but tool_calls must stay anchorable."""
|
|
assert "const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;" in UI_JS
|
|
assert "if(hasTc||hasTu||_messageHasReasoningPayload(m)) return true;" in UI_JS
|
|
|
|
|
|
def _run_js(script_body: str) -> dict:
|
|
script = textwrap.dedent(f"""
|
|
function loadSessionShape(messages, sessionToolCalls) {{
|
|
const filtered = (messages || []).filter(m => m && m.role);
|
|
const hasMessageToolMetadata = filtered.some(m => {{
|
|
if (!m || m.role !== 'assistant') return false;
|
|
const hasTc = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
|
|
const hasTu = Array.isArray(m.content) && m.content.some(p => p && p.type === 'tool_use');
|
|
return hasTc || hasTu;
|
|
}});
|
|
const toolCalls = (!hasMessageToolMetadata && sessionToolCalls && sessionToolCalls.length)
|
|
? sessionToolCalls.map(tc => ({{ ...tc, done: true }}))
|
|
: [];
|
|
return {{ filtered, hasMessageToolMetadata, toolCalls }};
|
|
}}
|
|
|
|
{script_body}
|
|
""")
|
|
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
|
return json.loads(proc.stdout)
|
|
|
|
|
|
def test_reload_keeps_empty_assistant_toolcall_anchor():
|
|
"""OpenAI-style assistant {content:'', tool_calls:[...]} must survive reload."""
|
|
result = _run_js("""
|
|
const messages = [
|
|
{ role: 'user', content: 'list files' },
|
|
{
|
|
role: 'assistant',
|
|
content: '',
|
|
tool_calls: [{ id: 'call-1', function: { name: 'terminal', arguments: '{}' } }]
|
|
},
|
|
{ role: 'tool', tool_call_id: 'call-1', content: '{"output":"ok"}' },
|
|
{ role: 'assistant', content: 'Done.' }
|
|
];
|
|
const loaded = loadSessionShape(messages, [{ name: 'terminal', assistant_msg_idx: 1 }]);
|
|
process.stdout.write(JSON.stringify({
|
|
filtered_len: loaded.filtered.length,
|
|
has_metadata: loaded.hasMessageToolMetadata,
|
|
fallback_len: loaded.toolCalls.length,
|
|
assistant_tool_idx: loaded.filtered.findIndex(m => m.role === 'assistant' && m.tool_calls),
|
|
tool_idx: loaded.filtered.findIndex(m => m.role === 'tool')
|
|
}));
|
|
""")
|
|
assert result["filtered_len"] == 4
|
|
assert result["has_metadata"] is True
|
|
assert result["fallback_len"] == 0
|
|
assert result["assistant_tool_idx"] == 1
|
|
assert result["tool_idx"] == 2
|
|
|
|
|
|
def test_reload_uses_session_summary_when_messages_have_no_tool_metadata():
|
|
"""Older sessions should still render from session.tool_calls on reload."""
|
|
result = _run_js("""
|
|
const messages = [
|
|
{ role: 'user', content: 'build site' },
|
|
{ role: 'assistant', content: 'Starting.' },
|
|
{ role: 'tool', content: '{"bytes_written": 4955}' },
|
|
{ role: 'assistant', content: '' }
|
|
];
|
|
const sessionToolCalls = [
|
|
{ name: 'write_file', assistant_msg_idx: 1, snippet: 'bytes_written', tid: '' }
|
|
];
|
|
const loaded = loadSessionShape(messages, sessionToolCalls);
|
|
process.stdout.write(JSON.stringify({
|
|
has_metadata: loaded.hasMessageToolMetadata,
|
|
fallback_len: loaded.toolCalls.length,
|
|
done_flag: loaded.toolCalls[0] && loaded.toolCalls[0].done === true
|
|
}));
|
|
""")
|
|
assert result["has_metadata"] is False
|
|
assert result["fallback_len"] == 1
|
|
assert result["done_flag"] is True
|