-
Notifications
You must be signed in to change notification settings - Fork 454
[rendering-scripts] Fix Pi log parser for the v3 streaming schema (empty step summaries) #45414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
558b1a9
5dfce3a
afe34de
57eef80
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,9 +61,15 @@ function parsePiLog(logContent) { | |
| }; | ||
| } | ||
|
|
||
| const logEntries = transformPiEntries(rawEntries); | ||
| // Pi CLI's `--mode json` output schema changed over time. Current builds emit a | ||
| // v3 streaming schema (session, turn_start, turn_end, tool_execution_start/end, agent_end) | ||
| // while older builds emitted a flat init/assistant/tool_use/tool_result/result schema. | ||
| // Detect which schema this log uses and transform accordingly so the step summary | ||
| // renders the conversation and token stats for both. | ||
| const useV3Schema = isPiV3Schema(rawEntries); | ||
| const logEntries = useV3Schema ? transformPiV3Entries(rawEntries) : transformPiEntries(rawEntries); | ||
|
|
||
| const resultEntry = rawEntries.find(e => e.type === "result"); | ||
| const stats = useV3Schema ? computePiV3Stats(rawEntries) : legacyPiStats(rawEntries); | ||
|
|
||
| const canonicalLogEntries = convertLegacyLogEntriesToCopilotEvents(logEntries, { sourceEngine: "pi" }); | ||
| const conversationResult = generateConversationMarkdown(canonicalLogEntries, { | ||
|
|
@@ -73,8 +79,7 @@ function parsePiLog(logContent) { | |
|
|
||
| let markdown = conversationResult.markdown; | ||
|
|
||
| if (resultEntry && resultEntry.stats) { | ||
| const stats = resultEntry.stats; | ||
| if (stats) { | ||
| const syntheticEntry = { | ||
| usage: { | ||
| input_tokens: stats.input_tokens || 0, | ||
|
|
@@ -181,6 +186,241 @@ function transformPiEntries(rawEntries) { | |
| return entries; | ||
| } | ||
|
|
||
| /** | ||
| * Detects whether the raw Pi entries use the v3 streaming schema. | ||
| * | ||
| * The v3 schema emits envelope events (session, turn_end, tool_execution_start/end, agent_end) | ||
| * that the legacy flat schema (init/assistant/tool_use/tool_result/result) never uses. | ||
| * A single marker event is enough to distinguish the two. | ||
| * | ||
| * @param {Array<any>} rawEntries - Raw parsed JSONL entries | ||
| * @returns {boolean} True when the log uses the v3 streaming schema | ||
| */ | ||
| function isPiV3Schema(rawEntries) { | ||
| for (const e of rawEntries) { | ||
| if (!e || typeof e.type !== "string") { | ||
| continue; | ||
| } | ||
| if ( | ||
| e.type === "turn_end" || | ||
| e.type === "turn_start" || | ||
| e.type === "agent_end" || | ||
| e.type === "agent_start" || | ||
| e.type === "tool_execution_start" || | ||
| e.type === "tool_execution_end" || | ||
| e.type === "message_update" || | ||
| (e.type === "session" && typeof e.version === "number") | ||
| ) { | ||
| return true; | ||
| } | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Transforms raw Pi v3 streaming entries into the canonical logEntries format. | ||
| * | ||
| * The v3 schema streams a message in fragments (message_start/message_update/message_end) | ||
| * and finalizes each turn with a `turn_end` event carrying the complete assistant message | ||
| * (`content[]` of `{type:"text"}` and `{type:"toolCall", id, name, arguments}`). Tool | ||
| * results arrive as `tool_execution_end` events keyed by `toolCallId`. We render from the | ||
| * finalized `turn_end` messages (avoiding fragment duplication) and pair each tool call | ||
| * with its result by id. | ||
| * | ||
| * Ordering matters: `generateConversationMarkdown` resolves a tool result's name from the | ||
| * preceding tool_use, so each turn emits its assistant tool_use entries first, then the | ||
| * matching tool_result entries — even though `tool_execution_end` precedes `turn_end` in | ||
| * the raw stream. | ||
| * | ||
| * @param {Array<any>} rawEntries - Raw parsed JSONL entries | ||
| * @returns {Array<any>} Canonical log entries for generateConversationMarkdown | ||
| */ | ||
| function transformPiV3Entries(rawEntries) { | ||
| /** @type {Array<any>} */ | ||
| const entries = []; | ||
|
|
||
| // Index tool execution results by their tool call id for pairing. | ||
| /** @type {Map<string, any>} */ | ||
| const resultsById = new Map(); | ||
| for (const raw of rawEntries) { | ||
| if (raw.type === "tool_execution_end" && raw.toolCallId) { | ||
| resultsById.set(raw.toolCallId, raw); | ||
| } | ||
| } | ||
|
|
||
| // Initialization entry from the session event, with the model taken from the first | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Detail and suggested fixThe v3 stream includes timing data that makes If the upstream const sessionEvent = rawEntries.find(e => e.type === "session");
const agentEndEvent = rawEntries.find(e => e.type === "agent_end");
let duration_ms = 0;
if (sessionEvent && sessionEvent.timestamp && agentEndEvent && agentEndEvent.timestamp) {
duration_ms = new Date(agentEndEvent.timestamp) - new Date(sessionEvent.timestamp);
if (!isFinite(duration_ms) || duration_ms < 0) duration_ms = 0;
}If |
||
| // finalized turn (v3 session events do not carry the model directly). | ||
| const session = rawEntries.find(e => e.type === "session"); | ||
| const modeledTurn = rawEntries.find(e => e.type === "turn_end" && e.message && e.message.model); | ||
| const model = modeledTurn ? modeledTurn.message.model : undefined; | ||
| if (session || model) { | ||
| entries.push({ | ||
| type: "system", | ||
| subtype: "init", | ||
| model: model, | ||
| session_id: session ? session.id : undefined, | ||
| }); | ||
| } | ||
|
|
||
| /** @type {Set<string>} */ | ||
| const emittedResults = new Set(); | ||
|
|
||
| for (const raw of rawEntries) { | ||
| if (raw.type !== "turn_end" || !raw.message || !Array.isArray(raw.message.content)) { | ||
| continue; | ||
| } | ||
|
|
||
| /** @type {Array<string>} */ | ||
| const pendingToolIds = []; | ||
| for (const part of raw.message.content) { | ||
| if (!part || typeof part !== "object") { | ||
| continue; | ||
| } | ||
| if (part.type === "text") { | ||
| const text = typeof part.text === "string" ? part.text : ""; | ||
| if (!text.trim()) { | ||
| continue; | ||
| } | ||
| entries.push({ | ||
| type: "assistant", | ||
| message: { content: [{ type: "text", text }] }, | ||
| }); | ||
| } else if (part.type === "toolCall") { | ||
| entries.push({ | ||
| type: "assistant", | ||
| message: { | ||
| content: [{ type: "tool_use", id: part.id, name: part.name, input: part.arguments || {} }], | ||
| }, | ||
| }); | ||
| if (part.id) { | ||
| pendingToolIds.push(part.id); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Emit tool results for this turn's calls, after their tool_use entries. | ||
| for (const id of pendingToolIds) { | ||
| if (emittedResults.has(id)) { | ||
| continue; | ||
| } | ||
| const res = resultsById.get(id); | ||
| if (!res) { | ||
| continue; | ||
| } | ||
| emittedResults.add(id); | ||
| entries.push({ | ||
| type: "user", | ||
| message: { | ||
| content: [ | ||
| { | ||
| type: "tool_result", | ||
| tool_use_id: id, | ||
| content: extractPiV3ResultText(res.result), | ||
| is_error: res.isError || isPiV3ResultError(res.result), | ||
| }, | ||
| ], | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return entries; | ||
| } | ||
|
|
||
| /** | ||
| * Extracts the textual output from a Pi v3 tool execution result. | ||
| * @param {any} result - The `result` field of a tool_execution_end event | ||
| * @returns {string} The concatenated text output | ||
| */ | ||
| function extractPiV3ResultText(result) { | ||
| if (!result) { | ||
| return ""; | ||
| } | ||
| if (typeof result === "string") { | ||
| return result; | ||
| } | ||
| if (Array.isArray(result.content)) { | ||
| return result.content.map(c => (c && typeof c.text === "string" ? c.text : typeof c === "string" ? c : "")).join(""); | ||
| } | ||
| if (typeof result.output === "string") { | ||
| return result.output; | ||
| } | ||
| return JSON.stringify(result); | ||
| } | ||
|
|
||
| /** | ||
| * Determines whether a Pi v3 tool execution result represents an error. | ||
| * @param {any} result - The `result` field of a tool_execution_end event | ||
| * @returns {boolean} True when the result indicates an error | ||
| */ | ||
| function isPiV3ResultError(result) { | ||
| return !!(result && (result.isError === true || result.is_error === true || result.status === "error")); | ||
| } | ||
|
|
||
| /** | ||
| * Computes aggregate stats from Pi v3 entries for the information section. | ||
| * | ||
| * Each `turn_end` carries a `usage` object; both output and input tokens are summed across | ||
| * turns, matching the accumulation performed by the Pi driver. Turn count is the number of | ||
| * finalized turns. | ||
| * | ||
| * @param {Array<any>} rawEntries - Raw parsed JSONL entries | ||
| * @returns {{input_tokens:number, output_tokens:number, turns:number, duration_ms:number}|null} Stats or null when unavailable | ||
| */ | ||
| function computePiV3Stats(rawEntries) { | ||
| let outputTokens = 0; | ||
| let inputTokens = 0; | ||
| let turns = 0; | ||
| let sawUsage = false; | ||
|
|
||
| for (const raw of rawEntries) { | ||
| if (raw.type !== "turn_end") { | ||
| continue; | ||
| } | ||
| turns++; | ||
| const usage = raw.message && raw.message.usage; | ||
| if (usage && typeof usage === "object") { | ||
| sawUsage = true; | ||
| if (typeof usage.output === "number") { | ||
| outputTokens += usage.output; | ||
| } | ||
| if (typeof usage.input === "number") { | ||
| inputTokens += usage.input; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (turns === 0 && !sawUsage) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/diagnosing-bugs] When 💡 Suggested fixChange the guard to also return if (!sawUsage) {
return null; // no token data available — omit the stats block entirely
}And add a test case: it('returns null when turns exist but no usage fields', () => {
expect(computePiV3Stats([{ type: 'turn_end', message: { content: [] } }])).toBeNull();
});@copilot please address this. |
||
| return null; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Detail and suggested fixThe null-guard at line ~395 is: if (turns === 0 && !sawUsage) {
return null;
}If Fix the guard: if (!sawUsage) {
return turns === 0 ? null : { input_tokens: 0, output_tokens: 0, turns, duration_ms: 0 };
}Or simply |
||
| } | ||
|
|
||
| return { | ||
| input_tokens: inputTokens, | ||
| output_tokens: outputTokens, | ||
| turns: turns, | ||
| duration_ms: 0, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Extracts stats from a legacy Pi `result` event, preserving the original flat-schema behavior. | ||
| * @param {Array<any>} rawEntries - Raw parsed JSONL entries | ||
| * @returns {{input_tokens:number, output_tokens:number, turns:number, duration_ms:number}|null} Stats or null when absent | ||
| */ | ||
| function legacyPiStats(rawEntries) { | ||
| const resultEntry = rawEntries.find(e => e.type === "result"); | ||
| if (!resultEntry || !resultEntry.stats) { | ||
| return null; | ||
| } | ||
| const stats = resultEntry.stats; | ||
| return { | ||
| input_tokens: stats.input_tokens || 0, | ||
| output_tokens: stats.output_tokens || 0, | ||
| turns: stats.turns || 0, | ||
| duration_ms: stats.duration_ms || 0, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether a canonical log entry is an assistant text entry eligible for merging | ||
| * with a subsequent streaming delta chunk. | ||
|
|
@@ -197,5 +437,8 @@ if (typeof module !== "undefined" && module.exports) { | |
| main, | ||
| parsePiLog, | ||
| transformPiEntries, | ||
| isPiV3Schema, | ||
| transformPiV3Entries, | ||
| computePiV3Stats, | ||
| }; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] 💡 Suggested fixAdd it('extracts stats from a legacy result event', () => {
const stats = legacyPiStats([{ type: 'result', stats: { input_tokens: 100, output_tokens: 50, turns: 3, duration_ms: 1500 } }]);
expect(stats).toEqual({ input_tokens: 100, output_tokens: 50, turns: 3, duration_ms: 1500 });
});
it('returns null when no legacy result event', () => {
expect(legacyPiStats([{ type: 'init' }])).toBeNull();
});@copilot please address this. |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isPiV3Schemareturnstrueon any single v3 event, forcing the entire log through the v3 path — a single stray v3 event in a legacy log silently drops the whole conversation.💡 Detail and suggested fix
Any single matching event (including a bare
{type:"agent_start"}injected by a wrapper script or log aggregator) causesisPiV3Schemato returntrue, after whichtransformPiV3Entriesprocesses the log as v3 and ignores all legacyassistant/tool_use/resultevents. The resulting conversation will be empty or severely truncated with no error surfaced.Two concrete failure scenarios:
trueand renders nothing.Consider requiring at least one
turn_end(the load-bearing event for v3 content) before committing to the v3 path:This is a stronger signal: v3 logs always finalize turns with
turn_end, so requiring both a v3 marker and at least oneturn_endeliminates false positives from stray envelope events.