Files
wehub-resource-sync f3d80b4628
Auto Release / auto-release (push) Failing after 1s
CI / lint (push) Failing after 0s
CI / screenshot-quality (push) Failing after 3s
CI / pr-policy (push) Has been skipped
CI / test (3.11) (push) Failing after 0s
CI / test (3.12) (push) Failing after 0s
CI / test (3.13) (push) Failing after 3s
CI / coverage (push) Failing after 1s
Legibility / legibility (push) Failing after 3s
chore: import upstream snapshot with attribution
2026-07-13 12:31:48 +08:00

555 lines
23 KiB
Python

#!/usr/bin/env python3
"""Tests for diff pair matching logic.
The viewer's "diff" feature compares consecutive API requests to show what changed.
Current behavior: find the previous request with the same model + isMainTurn flag.
Problem: when Claude Code spawns subagents (parallel LLM calls), requests from
different conversation threads interleave. Time/model-based matching pairs unrelated
requests, producing meaningless diffs.
Correct behavior: match by **message prefix** — if request B's messages[:N] == request A's
messages, then B extends A's conversation thread and they should be diffed together.
"""
import hashlib
import json
# ── Test data: real trace from trace_20260218_083822.jsonl ──
# Simplified to essential fields for testing
TRACE_ENTRIES = [
{ # idx 0: initial opus request
"model": "claude-opus-4-6",
"system": "",
"messages": [{"role": "user", "content": "foo"}],
},
{ # idx 1: haiku subagent (different task)
"model": "claude-haiku-4-5-20251001",
"system": "",
"messages": [{"role": "user", "content": "quota"}],
},
{ # idx 2: haiku subagent (web search)
"model": "claude-haiku-4-5-20251001",
"system": [{"type": "text", "text": "You are a web search agent"}],
"messages": [{"role": "user", "content": "search the web for latest Claude Code news"}],
},
{ # idx 3: opus main thread continues
"model": "claude-opus-4-6",
"system": [{"type": "text", "text": "You are Claude Code"}],
"messages": [{"role": "user", "content": "system-reminder skills"}],
},
{ # idx 4: haiku subagent A (search query 1)
"model": "claude-haiku-4-5-20251001",
"system": [{"type": "text", "text": "Search agent v1"}],
"messages": [{"role": "user", "content": "Perform a web search for Claude Code updates"}],
},
{ # idx 5: haiku subagent B (same user msg, different system prompt)
"model": "claude-haiku-4-5-20251001",
"system": [{"type": "text", "text": "Search agent v2"}],
"messages": [{"role": "user", "content": "Perform a web search for Claude Code updates"}],
},
{ # idx 6: opus main thread turn 2 (extends idx 3)
"model": "claude-opus-4-6",
"system": [{"type": "text", "text": "You are Claude Code v2"}],
"messages": [
{"role": "user", "content": "system-reminder skills"},
{"role": "assistant", "content": "I found some news..."},
{"role": "user", "content": "tell me more"},
],
},
{ # idx 7: opus main thread turn 3 (extends idx 6)
"model": "claude-opus-4-6",
"system": [{"type": "text", "text": "You are Claude Code v3"}],
"messages": [
{"role": "user", "content": "system-reminder skills"},
{"role": "assistant", "content": "I found some news..."},
{"role": "user", "content": "tell me more"},
{"role": "assistant", "content": "Here are the details..."},
{"role": "user", "content": "suggestion mode"},
],
},
{ # idx 8: haiku subagent (unrelated)
"model": "claude-haiku-4-5-20251001",
"system": [{"type": "text", "text": "Haiku writer"}],
"messages": [{"role": "user", "content": "now write a haiku"}],
},
{ # idx 9: opus main thread (extends idx 6, parallel branch from idx 7)
"model": "claude-opus-4-6",
"system": [{"type": "text", "text": "You are Claude Code v4"}],
"messages": [
{"role": "user", "content": "system-reminder skills"},
{"role": "assistant", "content": "I found some news..."},
{"role": "user", "content": "tell me more"},
{"role": "assistant", "content": "Here are the details v2..."},
{"role": "user", "content": "different follow up"},
],
},
{ # idx 10: opus main thread (extends idx 6, longer chain)
"model": "claude-opus-4-6",
"system": [{"type": "text", "text": "You are Claude Code v5"}],
"messages": [
{"role": "user", "content": "system-reminder skills"},
{"role": "assistant", "content": "I found some news..."},
{"role": "user", "content": "tell me more"},
{"role": "assistant", "content": "Here are the details..."},
{"role": "user", "content": "ok summarize"},
{"role": "assistant", "content": "Summary: ..."},
{"role": "user", "content": "suggestion mode"},
],
},
]
# ── Expected results ──
# For each entry, what's the correct "previous request" to diff against?
# None means it's a new thread with no parent.
EXPECTED_DIFF_PARENT = {
0: None, # new thread
1: None, # new thread (different content from idx 0)
2: None, # new thread (subagent)
3: None, # new thread (main agent start)
4: None, # new thread (subagent A)
5: 4, # same user message as idx 4 (but note: different system prompt)
6: 3, # extends idx 3 (messages[0] matches)
7: 6, # extends idx 6 (messages[:3] match)
8: None, # new thread (unrelated subagent)
9: 6, # extends idx 6 (messages[:3] match, diverges after)
10: 6, # extends idx 6 (messages[:3] match)
}
# What the CURRENT naive logic would produce (previous same-model request):
NAIVE_SAME_MODEL_PARENT = {
0: None, # first opus
1: None, # first haiku
2: 1, # prev haiku = idx 1 ❌ (unrelated)
3: 0, # prev opus = idx 0 ❌ (unrelated)
4: 2, # prev haiku = idx 2 ❌ (unrelated)
5: 4, # prev haiku = idx 4 ✅ (happens to be right)
6: 3, # prev opus = idx 3 ✅ (happens to be right)
7: 6, # prev opus = idx 6 ✅
8: 5, # prev haiku = idx 5 ❌ (unrelated)
9: 7, # prev opus = idx 7 ❌ (should be 6!)
10: 9, # prev opus = idx 9 ❌ (should be 6!)
}
def _msg_hash(msg: dict) -> str:
"""Hash a message by role + content for comparison."""
content = msg.get("content", "")
if isinstance(content, list):
content = json.dumps(content, sort_keys=True)
return hashlib.md5(f"{msg.get('role', '')}:{content}".encode()).hexdigest()[:8]
def _get_msg_hashes(entry: dict) -> list[str]:
"""Get message hashes for an entry."""
return [_msg_hash(m) for m in entry.get("messages", [])]
def _is_prefix_of(shorter: list[str], longer: list[str]) -> bool:
"""Check if shorter is a prefix of longer."""
if not shorter or len(longer) < len(shorter):
return False
return shorter == longer[: len(shorter)]
def find_diff_parent_by_prefix(entries: list[dict], idx: int) -> int | None:
"""Find the best diff parent for entry at idx using message prefix matching.
Returns the index of the entry whose messages are the longest prefix
of entries[idx]'s messages. Returns None if no prefix match found.
"""
target = entries[idx]
target_hashes = [_msg_hash(m) for m in target.get("messages", [])]
best_parent = None
best_match_len = 0
for j in range(idx):
candidate = entries[j]
candidate_hashes = [_msg_hash(m) for m in candidate.get("messages", [])]
if not candidate_hashes:
continue
# Check if candidate's messages are a prefix of target's messages
if len(target_hashes) >= len(candidate_hashes):
if target_hashes[: len(candidate_hashes)] == candidate_hashes:
if len(candidate_hashes) > best_match_len:
best_match_len = len(candidate_hashes)
best_parent = j
return best_parent
def find_next_by_prefix(entries: list[dict], idx: int) -> int | None:
"""Find the next entry whose messages start with entries[idx]'s messages as prefix.
Mirrors the JS findNextSameModel() — picks the closest (smallest) extension.
Returns None if no match found.
"""
current_hashes = _get_msg_hashes(entries[idx])
if not current_hashes:
return None
best_idx = None
best_len = float("inf")
for i in range(idx + 1, len(entries)):
candidate_hashes = _get_msg_hashes(entries[i])
if _is_prefix_of(current_hashes, candidate_hashes):
if len(candidate_hashes) < best_len:
best_len = len(candidate_hashes)
best_idx = i
return best_idx
def compute_nav_button_states(entries: list[dict], cur_idx: int) -> tuple[bool, bool]:
"""Compute whether prev/next nav buttons should be enabled for the diff at cur_idx.
Mirrors the JS updateNavButtons() logic after the bug fix.
Returns (prev_enabled, next_enabled).
"""
prev_idx = find_diff_parent_by_prefix(entries, cur_idx)
if prev_idx is None:
# No diff can be shown for this entry
return (False, False)
# prev button: enabled if prevIdx itself has a diff parent
prev_of_prev = find_diff_parent_by_prefix(entries, prev_idx)
prev_enabled = prev_of_prev is not None
# next button: enabled if there's a next entry that has a valid diff parent
next_idx = find_next_by_prefix(entries, cur_idx)
if next_idx is not None:
next_prev = find_diff_parent_by_prefix(entries, next_idx)
next_enabled = next_prev is not None
else:
next_enabled = False
return (prev_enabled, next_enabled)
def _response_id(entry: dict) -> str:
return entry.get("response_id", "")
def _previous_response_id(entry: dict) -> str:
return entry.get("previous_response_id", "")
def _codex_thread_key(entry: dict) -> str:
metadata = entry.get("client_metadata") or {}
turn_metadata = metadata.get("x-codex-turn-metadata") or {}
if isinstance(turn_metadata, str):
turn_metadata = json.loads(turn_metadata)
return f"{turn_metadata.get('session_id', '')}:{turn_metadata.get('thread_id', '')}"
def find_diff_parent_with_response_context(entries: list[dict], idx: int) -> int | None:
"""Mirror viewer parent matching: response id, Codex thread, then prefix."""
previous_id = _previous_response_id(entries[idx])
if previous_id:
for candidate_idx in range(idx - 1, -1, -1):
if _response_id(entries[candidate_idx]) == previous_id:
return candidate_idx
thread_key = _codex_thread_key(entries[idx])
if thread_key != ":":
for candidate_idx in range(idx - 1, -1, -1):
if _codex_thread_key(entries[candidate_idx]) == thread_key:
return candidate_idx
return find_diff_parent_by_prefix(entries, idx)
class TestDiffParentMatching:
"""Test that message-prefix-based matching produces correct diff pairs."""
def test_new_threads_have_no_parent(self):
"""Entries that start a new conversation thread should have no parent."""
for idx in [0, 1, 2, 3, 4, 8]:
result = find_diff_parent_by_prefix(TRACE_ENTRIES, idx)
assert result is None, f"idx {idx} should be a new thread (no parent), got parent={result}"
def test_continuation_finds_correct_parent(self):
"""Entries that continue a thread should find their correct parent."""
# idx 6 extends idx 3
assert find_diff_parent_by_prefix(TRACE_ENTRIES, 6) == 3
# idx 7 extends idx 6 (longest prefix match)
assert find_diff_parent_by_prefix(TRACE_ENTRIES, 7) == 6
# idx 9 extends idx 6 (diverges from idx 7 at msg[3])
assert find_diff_parent_by_prefix(TRACE_ENTRIES, 9) == 6
# idx 10 extends idx 6
assert find_diff_parent_by_prefix(TRACE_ENTRIES, 10) == 6
def test_same_content_different_system_prompt(self):
"""idx 5 has same user message as idx 4 — prefix match should work."""
result = find_diff_parent_by_prefix(TRACE_ENTRIES, 5)
assert result == 4
def test_prefers_longest_prefix(self):
"""When multiple entries could be parents, pick the longest prefix match."""
# idx 7's messages[:3] match idx 6, and messages[:1] match idx 3
# Should pick idx 6 (longer match)
result = find_diff_parent_by_prefix(TRACE_ENTRIES, 7)
assert result == 6, "Should prefer idx 6 (3 msgs match) over idx 3 (1 msg match)"
def test_naive_model_matching_is_wrong(self):
"""Demonstrate that naive same-model matching produces wrong results."""
wrong_cases = []
for idx, expected in EXPECTED_DIFF_PARENT.items():
find_diff_parent_by_prefix(TRACE_ENTRIES, idx)
naive = NAIVE_SAME_MODEL_PARENT[idx]
if naive != expected and expected is not None:
wrong_cases.append(idx)
# At least 2 cases where naive is wrong (idx 9, 10)
assert len(wrong_cases) >= 2, f"Expected at least 4 wrong naive matches, got {len(wrong_cases)}: {wrong_cases}"
def test_all_expected_parents(self):
"""Verify all expected diff parents match."""
for idx, expected in EXPECTED_DIFF_PARENT.items():
actual = find_diff_parent_by_prefix(TRACE_ENTRIES, idx)
assert actual == expected, f"idx {idx}: expected parent={expected}, got {actual}"
class TestEdgeCases:
"""Edge cases for diff matching."""
def test_empty_messages(self):
"""Entries with empty messages should not match anything."""
entries = [
{"model": "opus", "messages": []},
{"model": "opus", "messages": [{"role": "user", "content": "hi"}]},
]
assert find_diff_parent_by_prefix(entries, 0) is None
assert find_diff_parent_by_prefix(entries, 1) is None
def test_single_entry(self):
"""Single entry has no parent."""
entries = [{"model": "opus", "messages": [{"role": "user", "content": "hi"}]}]
assert find_diff_parent_by_prefix(entries, 0) is None
def test_exact_same_messages(self):
"""If two entries have identical messages, the earlier one is parent."""
entries = [
{"model": "opus", "messages": [{"role": "user", "content": "hi"}]},
{"model": "opus", "messages": [{"role": "user", "content": "hi"}]},
]
assert find_diff_parent_by_prefix(entries, 1) == 0
def test_cross_model_prefix_match(self):
"""Prefix matching should work across different models (same thread, model upgrade)."""
entries = [
{"model": "haiku", "messages": [{"role": "user", "content": "hi"}]},
{
"model": "opus",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "more"},
],
},
]
assert find_diff_parent_by_prefix(entries, 1) == 0
class TestFindNextByPrefix:
"""Test findNextSameModel logic (find the next entry in the conversation chain)."""
def test_finds_next_in_chain(self):
"""idx 3 -> idx 6 (next entry whose messages extend idx 3)."""
assert find_next_by_prefix(TRACE_ENTRIES, 3) == 6
def test_finds_closest_extension(self):
"""idx 6 has multiple successors (7, 9, 10); pick the closest (smallest msg count)."""
# idx 7 has 5 msgs, idx 9 has 5 msgs, idx 10 has 7 msgs
# Both 7 and 9 have 5 msgs — should pick whichever comes first (7)
result = find_next_by_prefix(TRACE_ENTRIES, 6)
assert result == 7
def test_no_next_for_leaf_node(self):
"""Entries at the end of a chain with no successors return None."""
# idx 10 has 7 messages — no entry after it extends this chain
assert find_next_by_prefix(TRACE_ENTRIES, 10) is None
def test_no_next_for_isolated_entry(self):
"""Isolated entries (no successors) return None."""
# idx 8 is an unrelated haiku subagent
assert find_next_by_prefix(TRACE_ENTRIES, 8) is None
def test_no_next_for_empty_messages(self):
"""Entry with empty messages cannot have a next."""
entries = [
{"model": "opus", "messages": []},
{"model": "opus", "messages": [{"role": "user", "content": "hi"}]},
]
assert find_next_by_prefix(entries, 0) is None
def test_chain_traversal(self):
"""Can traverse a full chain: 3 -> 6 -> 7."""
assert find_next_by_prefix(TRACE_ENTRIES, 3) == 6
assert find_next_by_prefix(TRACE_ENTRIES, 6) == 7
class TestResponsesDiffParentMatching:
"""Responses traces can carry stronger parent hints than message prefixes."""
def test_direct_previous_response_id_wins(self):
entries = [
{
"messages": [{"role": "user", "content": "first"}],
"response_id": "resp_1",
},
{
"messages": [{"role": "user", "content": "not a prefix"}],
"previous_response_id": "resp_1",
"response_id": "resp_2",
},
]
assert find_diff_parent_with_response_context(entries, 1) == 0
def test_codex_hidden_prefetch_uses_nearest_visible_same_thread(self):
metadata = {
"x-codex-turn-metadata": json.dumps(
{"session_id": "session-a", "thread_id": "thread-a"}, separators=(",", ":")
)
}
entries = [
{
"messages": [{"role": "user", "content": "shared initial prompt"}],
"client_metadata": metadata,
"response_id": "resp_1_1",
},
{
"messages": [
{"role": "user", "content": "shared initial prompt"},
{"role": "assistant", "content": "earlier final text"},
{"role": "user", "content": "limit compare"},
{"role": "assistant", "content": [{"type": "tool_use", "name": "tool_search"}]},
{"role": "tool", "content": [{"type": "tool_result", "content": "github tools"}]},
],
"client_metadata": metadata,
"response_id": "resp_3_2",
},
{
"messages": [
{"role": "user", "content": "shared initial prompt"},
{"role": "assistant", "content": "earlier final text"},
{"role": "user", "content": "figma domain"},
{"role": "tool", "content": [{"type": "tool_result", "content": "older github tools"}]},
],
"client_metadata": metadata,
"previous_response_id": "resp_hidden_prefetch",
"response_id": "resp_4_1",
},
]
assert find_diff_parent_by_prefix(entries, 2) == 0
assert find_diff_parent_with_response_context(entries, 2) == 1
def test_codex_thread_fallback_does_not_cross_threads(self):
entries = [
{
"messages": [{"role": "user", "content": "shared"}],
"client_metadata": {"x-codex-turn-metadata": {"session_id": "session-a", "thread_id": "thread-a"}},
"response_id": "resp_a",
},
{
"messages": [{"role": "user", "content": "different"}],
"client_metadata": {"x-codex-turn-metadata": {"session_id": "session-b", "thread_id": "thread-b"}},
"previous_response_id": "resp_hidden",
"response_id": "resp_b",
},
]
assert find_diff_parent_with_response_context(entries, 1) is None
class TestNavButtonStates:
"""Test diff navigation button enabled/disabled states.
This tests the logic that was buggy: updateNavButtons() compared the object
returned by findPrevSameModel() directly to a number instead of using .idx,
causing the right button to always be disabled and the left button to never
be disabled.
"""
def test_first_diff_in_chain_has_prev_disabled(self):
"""When viewing turn 3→6 (first diff pair), prev should be disabled."""
# cur_idx=6, prev_idx=3. prev of 3 is None, so prev button disabled.
prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 6)
assert not prev_enabled, "prev should be disabled at start of chain"
assert next_enabled, "next should be enabled (can go to 6→7)"
def test_middle_of_chain_prev_enabled(self):
"""When viewing turn 6→7, prev should be enabled (can go back to 3→6)."""
prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 7)
assert prev_enabled, "prev should be enabled (can go back to 3→6)"
# next is disabled because no entry after 7 extends its exact 5-message prefix
# (idx 10 diverges at msg[4]: "ok summarize" vs "suggestion mode")
assert not next_enabled
def test_end_of_chain_has_next_disabled(self):
"""At the last entry in a chain, next should be disabled."""
prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 10)
assert prev_enabled, "prev should be enabled (can go back)"
assert not next_enabled, "next should be disabled at end of chain"
def test_isolated_entry_with_parent_has_both_nav_correct(self):
"""idx 5 (extends idx 4): no next, prev depends on idx 4 having a parent."""
prev_enabled, next_enabled = compute_nav_button_states(TRACE_ENTRIES, 5)
# idx 4 has no parent (new thread), so prev disabled
assert not prev_enabled, "prev should be disabled (idx 4 has no parent)"
assert not next_enabled, "next should be disabled (no successor extends idx 5)"
def test_simple_two_entry_chain(self):
"""Simple chain of 2 entries: prev disabled on first diff, next disabled too."""
entries = [
{"model": "opus", "messages": [{"role": "user", "content": "hi"}]},
{
"model": "opus",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "more"},
],
},
]
prev_enabled, next_enabled = compute_nav_button_states(entries, 1)
assert not prev_enabled, "prev disabled (entry 0 has no parent)"
assert not next_enabled, "next disabled (no entry after 1)"
def test_three_entry_chain(self):
"""Chain of 3: A→B→C. At B, both enabled; at A→B, prev disabled; at B→C, next disabled."""
entries = [
{"model": "opus", "messages": [{"role": "user", "content": "hi"}]},
{
"model": "opus",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
],
},
{
"model": "opus",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "more"},
],
},
]
# At entry 1 (diff 0→1): prev disabled (0 has no parent), next enabled (2 exists)
p, n = compute_nav_button_states(entries, 1)
assert not p
assert n
# At entry 2 (diff 1→2): prev enabled (1 has parent 0), next disabled (no entry 3)
p, n = compute_nav_button_states(entries, 2)
assert p
assert not n