Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/transcript_viewer/templates/header.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
header = @view[:header]
stats = @view[:stats]
models = presence(stats[:models].to_a.join(", ")) || "unknown"
%><div class="header"><h1>Session: <%= h(header[:id] || "unknown") %></h1><div class="help-bar"><span class="help-hint">T toggle thinking · O toggle tools</span><div class="help-actions"><button type="button" class="header-toggle-btn" data-toggle="thinking" aria-pressed="false">Toggle thinking</button><button type="button" class="header-toggle-btn" data-toggle="tools" aria-pressed="false">Toggle tools</button><% if presence(@view[:export_href]) %><a class="download-json-btn" href="<%= h(@view[:export_href]) %>">↓ JSONL</a><% end %></div></div><div class="header-info"><%= info_item("Date:", format_time(header[:timestamp])) %><%= info_item("Models:", models) %><%= info_item("Messages:", message_parts(stats)) %><%= info_item("Tool Calls:", stats[:tool_calls]) %><%= info_item("Tokens:", token_parts(stats)) %><%= info_item("Cost:", "$0.000") %></div></div>
%><div class="header"><h1>Session: <%= h(header[:id] || "unknown") %></h1><div class="help-bar"><span class="help-hint">T toggle thinking · O toggle tools</span><div class="help-actions"><button type="button" class="header-toggle-btn" data-toggle="thinking" aria-pressed="false">Toggle thinking</button><button type="button" class="header-toggle-btn" data-toggle="tools" aria-pressed="false">Toggle tools</button><% if presence(@view[:export_href]) %><a class="download-json-btn" href="<%= h(@view[:export_href]) %>">↓ JSONL</a><% end %></div></div><div class="header-info"><%= info_item("Date:", format_time(header[:timestamp])) %><%= info_item("Models:", models) %><%= info_item("Messages:", message_parts(stats)) %><%= info_item("Tool Calls:", stats[:tool_calls]) %><%= info_item("Tokens:", token_parts(stats)) %><%= info_item("Cost:", format("$%.3f", stats[:cost])) %></div></div>
63 changes: 39 additions & 24 deletions lib/transcript_viewer/view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ def to_h
ancestor_entries = forked_transcript ? load_ancestor_entries(fork_parent_id) : []
raw_entries = ancestor_entries + session_entries
tool_name_by_call_id = build_tool_call_name_map(raw_entries)
ancestor_count = ancestor_entries.map { |entry| map_entry(entry, tool_name_by_call_id) }.compact.length
ancestor_count = ancestor_entries.sum { |entry| map_entries(entry, tool_name_by_call_id).length }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ancestor_count is now computed by summing the lengths of mapped entry arrays. Ensure that map_entries is pure and side-effect free, since it is called twice for ancestor entries — once here and once during the flat_map on raw_entries. If map_entries has any side effects or is expensive, this double invocation could be problematic. Consider computing it from the already-mapped entries slice instead.

entries = normalize_parent_links(
raw_entries.map { |entry| map_entry(entry, tool_name_by_call_id) }.compact,
raw_entries.flat_map { |entry| map_entries(entry, tool_name_by_call_id) },
raw_entries,
)
leaf_id = entries.last&.dig(:id)
Expand Down Expand Up @@ -166,39 +166,41 @@ def nearest_mapped_parent_id(parent_id, mapped_ids, raw_parent_by_id)
nil
end

def map_entry(entry, tool_name_by_call_id)
def map_entries(entry, tool_name_by_call_id)
case entry[:type]
when "message"
map_message_entry(entry, tool_name_by_call_id)
map_message_entries(entry, tool_name_by_call_id)
when "reasoning_change"
{
[{
id: entry[:id],
parentId: entry[:parent_id],
timestamp: entry[:timestamp],
type: "reasoningChange",
reasoning: entry.dig(:data, :reasoning).to_s,
}
}]
when "model_change"
{
[{
id: entry[:id],
parentId: entry[:parent_id],
timestamp: entry[:timestamp],
type: "modelChange",
modelId: entry.dig(:data, :model_id).to_s,
}
}]
when "compaction"
{
[{
id: entry[:id],
parentId: entry[:parent_id],
timestamp: entry[:timestamp],
type: "compaction",
summary: compaction_summary(entry),
tokensBefore: compaction_tokens_before(entry),
}
}]
else
[]
end
end

def map_message_entry(entry, tool_name_by_call_id)
def map_message_entries(entry, tool_name_by_call_id)
data = entry[:data]

mapped = {
Expand All @@ -210,16 +212,19 @@ def map_message_entry(entry, tool_name_by_call_id)
}

if data[:role] == "user" && tool_result_message?(data)
tool_result_block = data[:content].find { |block| block[:type] == "tool_result" }
mapped[:message] = {
role: "toolResult",
toolCallId: tool_result_block[:tool_use_id],
toolName: tool_name_by_call_id[tool_result_block[:tool_use_id]],
content: normalize_tool_result_content(tool_result_block[:content]),
isError: false,
details: nil,
}
return mapped
return data[:content].each_with_index.map do |tool_result_block, index|
mapped.merge(
id: tool_result_entry_id(entry[:id], index, data[:content].length),
message: {
role: "toolResult",
toolCallId: tool_result_block[:tool_use_id],
toolName: tool_name_by_call_id[tool_result_block[:tool_use_id]],
content: normalize_tool_result_content(tool_result_block[:content]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When iterating over data[:content] to map tool results, the code assumes every block in the content array is a tool_result block. If a user message contains mixed content (e.g., a text block alongside tool_result blocks), non-tool_result blocks will be incorrectly mapped as tool results (accessing block[:tool_use_id] on a text block will return nil). Consider filtering to only tool_result typed blocks before mapping.

isError: false,
details: nil,
},
)
end
end

mapped[:message] = {
Expand All @@ -232,7 +237,13 @@ def map_message_entry(entry, tool_name_by_call_id)
usage: normalize_usage(entry[:usage] || data[:usage]),
}

mapped
[mapped]
end

def tool_result_entry_id(event_id, index, result_count)
return event_id if result_count == 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool_result_entry_id method uses a colon-separated suffix format "#{event_id}:tool-result:#{index}". If event_id itself contains colons, this could cause ambiguity when parsing IDs later. Consider using a delimiter that is less likely to appear in event IDs, or document the ID format assumption.


"#{event_id}:tool-result:#{index}"
end

def tool_result_message?(data)
Expand All @@ -254,6 +265,7 @@ def normalize_usage(usage)
output: usage[:total_output_tokens] || usage[:output] || usage[:output_tokens] || output_details[:output_tokens] || 0,
cacheRead: usage[:cache_read] || input_details[:cache_read_input_tokens] || input_details[:cached_tokens] || 0,
cacheWrite: usage[:cache_write] || input_details[:cache_creation_input_tokens] || 0,
cost: (usage.dig(:cost, :total) || 0).to_f,
}
end

Expand Down Expand Up @@ -429,7 +441,7 @@ def build_tool_pair_entry(parent_entry:, tool_id:, tool_name:, input:, result_co
end

def compute_stats(entries)
stats = { developer: 0, user: 0, assistant: 0, tool_results: 0, compactions: 0, tool_calls: 0, tokens: Hash.new(0), models: Set.new }
stats = { developer: 0, user: 0, assistant: 0, tool_results: 0, tool_calls: 0, cost: 0.0, compactions: 0, tokens: Hash.new(0), models: Set.new }
entries.each do |entry|
if entry[:type] == "compaction"
stats[:compactions] += 1
Expand All @@ -446,7 +458,10 @@ def compute_stats(entries)
stats[:assistant] += 1
stats[:models] << [msg[:provider], msg[:model]].compact.join("/") if msg[:model]
stats[:tool_calls] += msg[:content].count { |block| block[:type] == "toolCall" }
[:input, :output, :cacheRead, :cacheWrite].each { |key| stats[:tokens][key] += msg[:usage][key].to_i } if msg[:usage]
if msg[:usage]
[:input, :output, :cacheRead, :cacheWrite].each { |key| stats[:tokens][key] += msg[:usage][key].to_i }
stats[:cost] += msg[:usage][:cost]
end
end
stats
end
Expand Down
39 changes: 38 additions & 1 deletion test/test_transcript_viewer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def test_renders_basic_session
model: "model",
content: [{ type: "text", text: "hello <world>" }],
},
usage: { total_input_tokens: 1000, total_output_tokens: 10 },
usage: { total_input_tokens: 1000, total_output_tokens: 10, cost: { total: "0.0028135" } },
},
],
export_href: "/export.jsonl",
Expand All @@ -29,6 +29,7 @@ def test_renders_basic_session
assert_includes html, "hello &lt;world&gt;"
assert_includes html, "test/model"
assert_includes html, "/export.jsonl"
assert_includes html, "Cost:</span><span class=\"info-value\">$0.003</span>"
end

def test_page_renders_composable_parts
Expand Down Expand Up @@ -83,6 +84,42 @@ def test_renders_tool_call_and_result
assert_includes html, "hi"
end

def test_renders_every_result_from_a_multi_tool_result_message
html = TranscriptViewer.render(
session_id: "parallel-tools",
events: [
{
id: "assistant-1",
type: "message",
data: {
role: "assistant",
content: [
{ type: "tool_use", id: "tool-1", name: "bash", input: { command: "echo first" } },
{ type: "tool_use", id: "tool-2", name: "bash", input: { command: "echo second" } },
],
},
},
{
id: "result-1",
parent_id: "assistant-1",
type: "message",
data: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "tool-1", content: [{ type: "text", text: "first result" }] },
{ type: "tool_result", tool_use_id: "tool-2", content: [{ type: "text", text: "second result" }] },
],
},
},
],
)

assert_includes html, "first result"
assert_includes html, "second result"
assert_equal 2, html.scan(/tool-execution success/).length
refute_includes html, "tool-execution pending"
end

def test_renders_linear_jsonl_without_parent_links
html = TranscriptViewer.render(
session_id: "linear",
Expand Down