feat: redesign chat transcript + fix streaming/persistence lifecycle — v0.50.70 (PR #587 by @aronprins)
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.
This commit is contained in:
@@ -1,216 +1,114 @@
|
||||
"""
|
||||
Regression tests for issue #401 / PR #402:
|
||||
Tool call cards show incorrect/duplicate entries on session load after context compaction.
|
||||
Regression tests for tool-card persistence on session reload.
|
||||
|
||||
Root cause: loadSession() applied its own B9 sanitization (producing a new message array
|
||||
with different indices) but did not remap the session-level tool_calls.assistant_msg_idx
|
||||
values to match. It then assigned the broken tool_calls directly to S.toolCalls, bypassing
|
||||
renderMessages()'s fallback that correctly derives tool calls from per-message tool_calls.
|
||||
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
|
||||
|
||||
Fix: build origIdxToSanitizedIdx during the B9 pass and remap each tc.assistant_msg_idx;
|
||||
set S.toolCalls=[] so renderMessages() uses the fallback derivation.
|
||||
|
||||
These tests verify the JS logic statically (no server needed).
|
||||
That broke both durable logging and page refresh for valid tool runs.
|
||||
"""
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import textwrap
|
||||
import json
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# --- Static structural checks ---
|
||||
|
||||
def test_loadsession_sets_toolcalls_empty():
|
||||
"""loadSession must set S.toolCalls=[] instead of pre-filling from session-level tool_calls."""
|
||||
assert "S.toolCalls=[]" in SESSIONS_JS, (
|
||||
"loadSession() must set S.toolCalls=[] so renderMessages() uses its fallback "
|
||||
"derivation from per-message tool_calls with correct sanitized-array indices"
|
||||
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_does_not_assign_broken_tool_calls():
|
||||
"""loadSession must NOT assign session.tool_calls directly to S.toolCalls (causes index mismatch)."""
|
||||
# The old broken pattern: S.toolCalls=(data.session.tool_calls||[]).map(tc=>({...tc,done:true}))
|
||||
assert "S.toolCalls=(data.session.tool_calls" not in SESSIONS_JS, (
|
||||
"loadSession() must not assign session-level tool_calls directly to S.toolCalls — "
|
||||
"those indices are relative to the pre-sanitization array and will be wrong after B9 filtering"
|
||||
)
|
||||
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_loadsession_builds_idx_remap():
|
||||
"""loadSession must build an origIdxToSanitizedIdx map during B9 sanitization."""
|
||||
assert "origIdxToSanitizedIdx" in SESSIONS_JS, (
|
||||
"loadSession() must build origIdxToSanitizedIdx during B9 sanitization "
|
||||
"to remap session-level tool_calls.assistant_msg_idx"
|
||||
)
|
||||
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 test_loadsession_remaps_assistant_msg_idx():
|
||||
"""loadSession must remap tc.assistant_msg_idx using the index map."""
|
||||
assert "tc.assistant_msg_idx" in SESSIONS_JS, (
|
||||
"loadSession() must update tc.assistant_msg_idx using the sanitized index map"
|
||||
)
|
||||
|
||||
|
||||
# --- Behavioural Node.js tests ---
|
||||
|
||||
def _run_js(script_body: str) -> dict:
|
||||
"""Run a JS snippet that exercises the B9 sanitization logic extracted from sessions.js."""
|
||||
# Extract just the B9 + index-remap block from loadSession
|
||||
# We'll re-implement it inline for testability
|
||||
script = textwrap.dedent(f"""
|
||||
// Simulate the B9 sanitization + index remap logic from loadSession()
|
||||
function sanitizeAndRemap(messages, tool_calls) {{
|
||||
const allMsgs = messages || [];
|
||||
const sanitized = [];
|
||||
const origIdxToSanitizedIdx = {{}};
|
||||
let lastKeptAsstIdx = -1;
|
||||
for (let i = 0; i < allMsgs.length; i++) {{
|
||||
const m = allMsgs[i];
|
||||
if (!m || !m.role) continue;
|
||||
if (m.role === 'tool') continue;
|
||||
if (m.role === 'assistant') {{
|
||||
let c = m.content || '';
|
||||
if (Array.isArray(c)) c = c.filter(p => p && p.type === 'text').map(p => p.text || '').join('');
|
||||
if (!String(c).trim().length) {{ continue; }}
|
||||
lastKeptAsstIdx = sanitized.length;
|
||||
}}
|
||||
origIdxToSanitizedIdx[i] = sanitized.length;
|
||||
sanitized.push(m);
|
||||
}}
|
||||
const remapped = (tool_calls || []).map(tc => {{
|
||||
if (!tc || tc.assistant_msg_idx === undefined) return tc;
|
||||
const origIdx = tc.assistant_msg_idx;
|
||||
const newIdx = (origIdx in origIdxToSanitizedIdx)
|
||||
? origIdxToSanitizedIdx[origIdx]
|
||||
: (lastKeptAsstIdx >= 0 ? lastKeptAsstIdx : -1);
|
||||
return {{ ...tc, assistant_msg_idx: newIdx }};
|
||||
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;
|
||||
}});
|
||||
return {{ sanitized, remapped }};
|
||||
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
|
||||
)
|
||||
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_b9_remaps_tool_call_idx_after_empty_assistant_filtered():
|
||||
"""Tool call pointing to index 1 (empty assistant at orig idx 1, kept at idx 0) remaps correctly."""
|
||||
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: 'hello' }, // orig 0 -> sanitized 0
|
||||
{ role: 'assistant', content: '' }, // orig 1 -> FILTERED (empty)
|
||||
{ role: 'assistant', content: 'done.' }, // orig 2 -> sanitized 1
|
||||
{ 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 tool_calls = [
|
||||
{ name: 'terminal', assistant_msg_idx: 1 }, // pointed to filtered-out empty assistant
|
||||
{ name: 'read_file', assistant_msg_idx: 2 }, // pointed to kept assistant
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
const loaded = loadSessionShape(messages, [{ name: 'terminal', assistant_msg_idx: 1 }]);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_new_idx: remapped[0].assistant_msg_idx, // should attach to lastKeptAsstIdx = 1
|
||||
tc1_new_idx: remapped[1].assistant_msg_idx, // should remap 2 -> 1
|
||||
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["sanitized_length"] == 2, f"Expected 2 messages after B9, got {result['sanitized_length']}"
|
||||
assert result["tc0_new_idx"] == 1, (
|
||||
f"Tool call pointing to filtered empty assistant should attach to last kept assistant (idx 1), got {result['tc0_new_idx']}"
|
||||
)
|
||||
assert result["tc1_new_idx"] == 1, (
|
||||
f"Tool call pointing to orig idx 2 should remap to sanitized idx 1, got {result['tc1_new_idx']}"
|
||||
)
|
||||
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_b9_remaps_multiple_empty_assistants():
|
||||
"""Multiple consecutive empty assistants all remap to the last (nearest) kept assistant.
|
||||
|
||||
Note: the remapping pass runs after the full sanitization loop, so lastKeptAsstIdx
|
||||
already reflects the final kept-assistant position. This means even empty-assistant
|
||||
tool calls that came BEFORE the kept assistant get attached to it — which is correct
|
||||
behavior for context-compacted sessions where all tool calls belong to the one
|
||||
non-empty assistant response.
|
||||
"""
|
||||
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: 'go' }, // orig 0 -> sanitized 0
|
||||
{ role: 'assistant', content: '' }, // orig 1 -> FILTERED
|
||||
{ role: 'assistant', content: '' }, // orig 2 -> FILTERED
|
||||
{ role: 'assistant', content: '' }, // orig 3 -> FILTERED
|
||||
{ role: 'assistant', content: 'result' }, // orig 4 -> sanitized 1
|
||||
{ role: 'user', content: 'build site' },
|
||||
{ role: 'assistant', content: 'Starting.' },
|
||||
{ role: 'tool', content: '{"bytes_written": 4955}' },
|
||||
{ role: 'assistant', content: '' }
|
||||
];
|
||||
const tool_calls = [
|
||||
{ name: 'a', assistant_msg_idx: 1 },
|
||||
{ name: 'b', assistant_msg_idx: 2 },
|
||||
{ name: 'c', assistant_msg_idx: 3 },
|
||||
{ name: 'd', assistant_msg_idx: 4 },
|
||||
const sessionToolCalls = [
|
||||
{ name: 'write_file', assistant_msg_idx: 1, snippet: 'bytes_written', tid: '' }
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
const loaded = loadSessionShape(messages, sessionToolCalls);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_idx: remapped[0].assistant_msg_idx,
|
||||
tc1_idx: remapped[1].assistant_msg_idx,
|
||||
tc2_idx: remapped[2].assistant_msg_idx,
|
||||
tc3_idx: remapped[3].assistant_msg_idx,
|
||||
has_metadata: loaded.hasMessageToolMetadata,
|
||||
fallback_len: loaded.toolCalls.length,
|
||||
done_flag: loaded.toolCalls[0] && loaded.toolCalls[0].done === true
|
||||
}));
|
||||
""")
|
||||
assert result["sanitized_length"] == 2
|
||||
# Tool calls from filtered empty assistants: after the full loop, lastKeptAsstIdx=1,
|
||||
# so all filtered-assistant tool calls correctly attach to the kept assistant at idx 1.
|
||||
assert result["tc0_idx"] == 1, f"Expected 1 (last kept asst), got {result['tc0_idx']}"
|
||||
assert result["tc1_idx"] == 1
|
||||
assert result["tc2_idx"] == 1
|
||||
# Tool call from the kept assistant at orig idx 4 -> sanitized idx 1
|
||||
assert result["tc3_idx"] == 1, f"Expected 1, got {result['tc3_idx']}"
|
||||
|
||||
|
||||
def test_b9_no_filtering_needed_indices_preserved():
|
||||
"""When no empty assistant messages exist, indices should pass through unchanged."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'hi' }, // orig 0 -> sanitized 0
|
||||
{ role: 'assistant', content: 'hello' }, // orig 1 -> sanitized 1
|
||||
{ role: 'user', content: 'more' }, // orig 2 -> sanitized 2
|
||||
{ role: 'assistant', content: 'yes' }, // orig 3 -> sanitized 3
|
||||
];
|
||||
const tool_calls = [
|
||||
{ name: 'x', assistant_msg_idx: 1 },
|
||||
{ name: 'y', assistant_msg_idx: 3 },
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_idx: remapped[0].assistant_msg_idx,
|
||||
tc1_idx: remapped[1].assistant_msg_idx,
|
||||
}));
|
||||
""")
|
||||
assert result["sanitized_length"] == 4
|
||||
assert result["tc0_idx"] == 1, f"Expected 1, got {result['tc0_idx']}"
|
||||
assert result["tc1_idx"] == 3, f"Expected 3, got {result['tc1_idx']}"
|
||||
|
||||
|
||||
def test_b9_tool_role_messages_filtered():
|
||||
"""Messages with role='tool' must be filtered out and not affect index mapping."""
|
||||
result = _run_js("""
|
||||
const messages = [
|
||||
{ role: 'user', content: 'run' }, // orig 0 -> sanitized 0
|
||||
{ role: 'tool', content: 'output' }, // orig 1 -> FILTERED (tool role)
|
||||
{ role: 'assistant', content: 'done' }, // orig 2 -> sanitized 1
|
||||
];
|
||||
const tool_calls = [
|
||||
{ name: 'terminal', assistant_msg_idx: 2 },
|
||||
];
|
||||
const { sanitized, remapped } = sanitizeAndRemap(messages, tool_calls);
|
||||
process.stdout.write(JSON.stringify({
|
||||
sanitized_length: sanitized.length,
|
||||
tc0_idx: remapped[0].assistant_msg_idx,
|
||||
}));
|
||||
""")
|
||||
assert result["sanitized_length"] == 2, f"tool-role message must be filtered, got {result['sanitized_length']}"
|
||||
assert result["tc0_idx"] == 1, f"Expected orig idx 2 -> sanitized idx 1, got {result['tc0_idx']}"
|
||||
assert result["has_metadata"] is False
|
||||
assert result["fallback_len"] == 1
|
||||
assert result["done_flag"] is True
|
||||
|
||||
@@ -602,6 +602,8 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se
|
||||
src = (REPO_ROOT / "static/messages.js").read_text()
|
||||
assert "let reasoningText=''" in src, \
|
||||
"messages.js must track streamed reasoning text separately from assistant text"
|
||||
assert "let liveReasoningText=''" in src or 'let liveReasoningText = ""' in src, \
|
||||
"messages.js must track the currently active reasoning segment separately from cumulative reasoning"
|
||||
assert "source.addEventListener('reasoning'" in src or 'source.addEventListener("reasoning"' in src, \
|
||||
"messages.js must listen for live reasoning SSE events"
|
||||
assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \
|
||||
@@ -619,6 +621,71 @@ def test_ui_js_can_upgrade_thinking_spinner_into_live_reasoning_card(cleanup_tes
|
||||
"ui.js must centralize thinking row markup so it can switch between spinner and live text"
|
||||
assert "function updateThinking(text=''){appendThinking(text);}" in src or 'function updateThinking(text=""){appendThinking(text);}' in src, \
|
||||
"ui.js must expose an updateThinking helper for live reasoning rendering"
|
||||
assert "function finalizeThinkingCard()" in src, \
|
||||
"ui.js must expose a helper to finalize one live thinking card before starting another"
|
||||
|
||||
|
||||
def test_ui_js_keeps_split_thinking_cards_and_assistant_header(cleanup_test_sessions):
|
||||
"""R19b: settled render should keep distinct thinking cards for split assistant
|
||||
turns inside a single assistant turn container, preserving one assistant header
|
||||
for the whole response while keeping multiple thinking cards distinct.
|
||||
"""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
assert "pendingTurnThinking" not in src, \
|
||||
"renderMessages must not merge distinct thinking blocks into one settled card"
|
||||
assert "_createAssistantTurn(" in src, \
|
||||
"renderMessages must build a shared assistant turn wrapper instead of separate top-level rows"
|
||||
assert "assistant-segment" in src, \
|
||||
"settled assistant turns must preserve per-message segments for multiple thinking/tool/result blocks"
|
||||
|
||||
|
||||
def test_ui_js_keeps_reasoning_only_assistant_messages_visible(cleanup_test_sessions):
|
||||
"""R19c: assistant messages that only contain reasoning must still survive
|
||||
rerenders, otherwise prior thinking cards disappear on the next turn.
|
||||
"""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
assert "function _messageHasReasoningPayload(m)" in src, \
|
||||
"ui.js must detect reasoning-only assistant messages"
|
||||
assert "hasTc||hasTu||_messageHasReasoningPayload(m)" in src.replace(' ', ''), \
|
||||
"renderMessages visibility filter must preserve reasoning-only assistant messages"
|
||||
|
||||
|
||||
def test_ui_js_does_not_hide_anchor_segments_that_contain_thinking(cleanup_test_sessions):
|
||||
"""R19c2: assistant anchor segments that contain a thinking card must remain
|
||||
visible; only truly empty tool-call anchor segments should be hidden.
|
||||
"""
|
||||
src = (REPO_ROOT / "static" / "ui.js").read_text()
|
||||
compact = src.replace(' ', '').replace('\n', '')
|
||||
assert "}elseif(!thinkingText){" in compact, \
|
||||
"renderMessages must only hide assistant anchor segments when they have no thinking content"
|
||||
|
||||
|
||||
def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_test_sessions):
|
||||
"""R19d: live streaming must reuse the existing live assistant turn wrapper created
|
||||
by appendThinking(), otherwise the header gets recreated when answer tokens start.
|
||||
"""
|
||||
src = (REPO_ROOT / "static" / "messages.js").read_text()
|
||||
assert "function ensureAssistantRow(force=false)" in src or 'function ensureAssistantRow(force = false)' in src, \
|
||||
"ensureAssistantRow should manage the live assistant content segment"
|
||||
assert "let turn=$('liveAssistantTurn');" in src, \
|
||||
"ensureAssistantRow must bind to the existing live assistant turn wrapper"
|
||||
assert "appendThinking();" in src, \
|
||||
"ensureAssistantRow should create the live turn via appendThinking() when needed"
|
||||
assert "assistantRow.className='assistant-segment';" in src or 'assistantRow.className = \'assistant-segment\';' in src, \
|
||||
"live answer content should be appended as a segment inside the live turn wrapper"
|
||||
assert "if(!force&&!assistantRow){" in src.replace(' ', ''), \
|
||||
"ensureAssistantRow must still avoid creating the live answer segment when no display text exists yet"
|
||||
assert "if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow();" in src, \
|
||||
"token handler must only create the live answer segment once visible answer text starts"
|
||||
|
||||
|
||||
def test_messages_js_finalizes_thinking_card_before_tool_card(cleanup_test_sessions):
|
||||
"""R19e: later reasoning after a tool call must render in a fresh card."""
|
||||
src = (REPO_ROOT / "static/messages.js").read_text()
|
||||
assert "finalizeThinkingCard" in src, \
|
||||
"tool handler must finalize the current live thinking card before appending a tool card"
|
||||
assert "liveReasoningText='';" in src or 'liveReasoningText = "";' in src, \
|
||||
"tool handler must reset the active reasoning segment before post-tool reasoning arrives"
|
||||
|
||||
|
||||
# ── R17: Stack traces must not leak to clients in 500 responses ────────────
|
||||
|
||||
@@ -80,3 +80,24 @@ def test_workspace_panel_restore_before_sync():
|
||||
assert sync_pos >= 0, "syncWorkspacePanelState call must be present in boot IIFE"
|
||||
assert restore_pos < sync_pos, \
|
||||
"Workspace panel restore must happen BEFORE syncWorkspacePanelState() so the correct mode is applied"
|
||||
|
||||
|
||||
def test_workspace_panel_preload_marker_restored_in_head():
|
||||
"""index.html must preload the workspace panel state before the main stylesheet paints."""
|
||||
marker = "document.documentElement.dataset.workspacePanel"
|
||||
css_link = '<link rel="stylesheet" href="static/style.css">'
|
||||
marker_pos = HTML.find(marker)
|
||||
css_pos = HTML.find(css_link)
|
||||
assert marker_pos >= 0, "index.html must preload documentElement.dataset.workspacePanel from localStorage"
|
||||
assert css_pos >= 0, "main stylesheet link missing from index.html"
|
||||
assert marker_pos < css_pos, \
|
||||
"workspace panel preload marker must be set before style.css loads to avoid first-paint flash"
|
||||
|
||||
|
||||
def test_workspace_panel_mode_syncs_document_dataset():
|
||||
"""_setWorkspacePanelMode must update documentElement.dataset.workspacePanel for runtime parity."""
|
||||
fn_idx = BOOT_JS.find("function _setWorkspacePanelMode(")
|
||||
fn_end = BOOT_JS.find("\n}", fn_idx) + 2
|
||||
fn_body = BOOT_JS[fn_idx:fn_end]
|
||||
assert "document.documentElement.dataset.workspacePanel" in fn_body, \
|
||||
"_setWorkspacePanelMode must keep documentElement.dataset.workspacePanel in sync with the panel state"
|
||||
|
||||
@@ -428,3 +428,29 @@ def test_rendermessages_reads_reasoning_from_messages():
|
||||
# Specifically, the fallback that reads from top-level m.reasoning field
|
||||
assert 'thinkingText=m.reasoning' in src.replace(' ', ''), \
|
||||
"thinkingText=m.reasoning assignment not found in ui.js renderMessages"
|
||||
|
||||
|
||||
def test_streaming_restores_prior_reasoning_metadata_after_followup():
|
||||
"""Previous-turn thinking must survive later turns.
|
||||
|
||||
The provider-facing history strips WebUI-only `reasoning` fields, so the
|
||||
streaming path must merge that metadata back onto the returned message
|
||||
history before saving the session, including reinserting dropped
|
||||
reasoning-only assistant segments.
|
||||
"""
|
||||
src = (REPO / 'api' / 'streaming.py').read_text()
|
||||
assert "def _restore_reasoning_metadata(" in src, \
|
||||
"streaming.py must define a helper to restore prior reasoning metadata"
|
||||
assert "s.messages = _restore_reasoning_metadata(" in src, \
|
||||
"streaming.py must merge prior reasoning metadata back after run_conversation()"
|
||||
assert "updated_messages.insert(safe_pos, copy.deepcopy(prev_msg))" in src, \
|
||||
"streaming.py must reinsert dropped reasoning-only assistant messages"
|
||||
|
||||
|
||||
def test_routes_restores_prior_reasoning_metadata_after_followup():
|
||||
"""The non-streaming route path must preserve prior reasoning metadata too."""
|
||||
src = (REPO / 'api' / 'routes.py').read_text()
|
||||
assert "_restore_reasoning_metadata" in src, \
|
||||
"routes.py must import reasoning metadata restoration helper"
|
||||
assert 's.messages = _restore_reasoning_metadata(' in src, \
|
||||
"routes.py must merge prior reasoning metadata back after run_conversation()"
|
||||
|
||||
71
tests/test_tool_call_persistence.py
Normal file
71
tests/test_tool_call_persistence.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Tests for backend tool-call summary extraction used by WebUI session persistence."""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from api.streaming import _extract_tool_calls_from_messages
|
||||
|
||||
|
||||
def test_extract_tool_calls_from_openai_message_linkage():
|
||||
messages = [
|
||||
{"role": "user", "content": "ls"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call-1",
|
||||
"function": {"name": "terminal", "arguments": '{"command":"ls"}'},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call-1",
|
||||
"content": '{"output":"file.txt","exit_code":0}',
|
||||
},
|
||||
]
|
||||
result = _extract_tool_calls_from_messages(messages)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "terminal"
|
||||
assert result[0]["assistant_msg_idx"] == 1
|
||||
assert result[0]["snippet"] == "file.txt"
|
||||
|
||||
|
||||
def test_extract_tool_calls_falls_back_to_live_progress_when_ids_missing():
|
||||
messages = [
|
||||
{"role": "user", "content": "write spec"},
|
||||
{"role": "assistant", "content": "Starting."},
|
||||
{"role": "tool", "content": '{"bytes_written":4955}'},
|
||||
{"role": "assistant", "content": ""},
|
||||
]
|
||||
live_tool_calls = [{"name": "write_file", "args": {"path": "/tmp/SPEC.md"}}]
|
||||
result = _extract_tool_calls_from_messages(messages, live_tool_calls=live_tool_calls)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "write_file"
|
||||
assert result[0]["assistant_msg_idx"] == 1
|
||||
assert "bytes_written" in result[0]["snippet"]
|
||||
assert result[0]["args"]["path"] == "/tmp/SPEC.md"
|
||||
|
||||
|
||||
def test_extract_tool_calls_preserves_mixed_linked_and_fallback_results():
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": '{"command":"pwd"}'}}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call-1", "content": '{"output":"/tmp"}'},
|
||||
{"role": "assistant", "content": "Next"},
|
||||
{"role": "tool", "content": '{"result":"saved"}'},
|
||||
]
|
||||
live_tool_calls = [
|
||||
{"name": "terminal", "args": {"command": "pwd"}},
|
||||
{"name": "write_file", "args": {"path": "/tmp/out.txt"}},
|
||||
]
|
||||
result = _extract_tool_calls_from_messages(messages, live_tool_calls=live_tool_calls)
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "terminal"
|
||||
assert result[1]["name"] == "write_file"
|
||||
assert result[1]["assistant_msg_idx"] == 2
|
||||
assert result[1]["snippet"] == "saved"
|
||||
43
tests/test_ui_card_animation.py
Normal file
43
tests/test_ui_card_animation.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
|
||||
STYLE_CSS = (pathlib.Path(__file__).parent.parent / "static" / "style.css").read_text(encoding="utf-8")
|
||||
UI_JS = (pathlib.Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
COMPACT_CSS = re.sub(r"\s+", "", STYLE_CSS)
|
||||
|
||||
|
||||
def test_tool_card_toggle_uses_transformable_layout_and_transition():
|
||||
assert ".tool-card-toggle{" in COMPACT_CSS
|
||||
assert "display:inline-flex" in COMPACT_CSS
|
||||
assert "transition:transform.18sease" in COMPACT_CSS
|
||||
|
||||
|
||||
def test_tool_card_detail_uses_transitionable_collapsed_state():
|
||||
assert ".tool-card-detail{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
|
||||
assert re.search(
|
||||
r"\.tool-card\.open\s+\.tool-card-detail\s*\{[^}]*max-height:\s*520px;[^}]*opacity:\s*1;",
|
||||
STYLE_CSS,
|
||||
)
|
||||
|
||||
|
||||
def test_thinking_card_toggle_and_body_use_animation_friendly_state():
|
||||
assert ".thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;" in COMPACT_CSS
|
||||
assert ".thinking-card-body{display:block;max-height:0;opacity:0;overflow:hidden;" in COMPACT_CSS
|
||||
assert re.search(
|
||||
r"\.thinking-card\.open\s+\.thinking-card-body\s*\{[^}]*max-height:\s*300px;[^}]*opacity:\s*1;",
|
||||
STYLE_CSS,
|
||||
)
|
||||
|
||||
|
||||
def test_tool_card_toggle_uses_same_chevron_icon_markup_as_thinking_card():
|
||||
assert "<span class=\"thinking-card-toggle\">${li('chevron-right',12)}</span>" in UI_JS
|
||||
assert "<span class=\"tool-card-toggle\">${li('chevron-right',12)}</span>" in UI_JS
|
||||
|
||||
|
||||
def test_thinking_card_uses_panel_chrome_with_gold_palette():
|
||||
assert re.search(
|
||||
r"\.thinking-card\s*\{[^}]*background:\s*rgba\(201,168,76,.05\);[^}]*border:\s*1px\s+solid\s+rgba\(201,168,76,.18\);[^}]*border-radius:\s*8px;",
|
||||
STYLE_CSS,
|
||||
)
|
||||
assert "border-left: 2px solid rgba(201,168,76,.4);" not in STYLE_CSS
|
||||
Reference in New Issue
Block a user