From 558b1a9625cbd302d74b21685f36719c0e1ef62f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:46:51 +0000 Subject: [PATCH 1/3] Fix Pi log parser for v3 streaming schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pi CLI's `--print --mode json` output now emits a v3 streaming schema (session / turn_start / turn_end / tool_execution_* / agent_end), but parse_pi_log.cjs only understood the legacy flat schema (init/assistant/tool_use/tool_result/result). As a result, every current Pi-engine run rendered an empty step summary — no conversation, no tool calls, no token/turn stats — and the OTEL result-entry enrichment was skipped because no `result` event exists in v3. Add additive v3 support: detect the schema and, for v3, render from the finalized `turn_end` messages (text + toolCall) paired with `tool_execution_end` results by id, emitting tool_use before tool_result so the shared renderer resolves tool names and orders the conversation correctly. Token stats sum per-turn output and take peak per-turn input. The legacy path is unchanged. Adds v3 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- actions/setup/js/parse_pi_log.cjs | 252 ++++++++++++++++++++++++- actions/setup/js/parse_pi_log.test.cjs | 99 +++++++++- 2 files changed, 346 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/parse_pi_log.cjs b/actions/setup/js/parse_pi_log.cjs index d0edb48fd6f..1c3da23ba04 100644 --- a/actions/setup/js/parse_pi_log.cjs +++ b/actions/setup/js/parse_pi_log.cjs @@ -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_* / 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,242 @@ 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_*/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} 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} rawEntries - Raw parsed JSONL entries + * @returns {Array} Canonical log entries for generateConversationMarkdown + */ +function transformPiV3Entries(rawEntries) { + /** @type {Array} */ + const entries = []; + + // Index tool execution results by their tool call id for pairing. + /** @type {Map} */ + 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 + // 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} */ + const emittedResults = new Set(); + + for (const raw of rawEntries) { + if (raw.type !== "turn_end" || !raw.message || !Array.isArray(raw.message.content)) { + continue; + } + + /** @type {Array} */ + 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: 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 cumulative-context `usage` object; output tokens are summed + * across turns while input (prompt) tokens use the maximum observed value, since the + * prompt context is resent each turn rather than accumulated. Turn count is the number of + * finalized turns. + * + * @param {Array} 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 maxInputTokens = 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" && usage.input > maxInputTokens) { + maxInputTokens = usage.input; + } + } + } + + if (turns === 0 && !sawUsage) { + return null; + } + + return { + input_tokens: maxInputTokens, + output_tokens: outputTokens, + turns: turns, + duration_ms: 0, + }; +} + +/** + * Extracts stats from a legacy Pi `result` event, preserving the original flat-schema behavior. + * @param {Array} 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 +438,8 @@ if (typeof module !== "undefined" && module.exports) { main, parsePiLog, transformPiEntries, + isPiV3Schema, + transformPiV3Entries, + computePiV3Stats, }; } diff --git a/actions/setup/js/parse_pi_log.test.cjs b/actions/setup/js/parse_pi_log.test.cjs index 6108362b573..e85a83a477b 100644 --- a/actions/setup/js/parse_pi_log.test.cjs +++ b/actions/setup/js/parse_pi_log.test.cjs @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; describe("parse_pi_log.cjs", () => { let mockCore; - let parsePiLog, transformPiEntries; + let parsePiLog, transformPiEntries, isPiV3Schema, transformPiV3Entries, computePiV3Stats; beforeEach(async () => { mockCore = { @@ -22,6 +22,9 @@ describe("parse_pi_log.cjs", () => { const module = await import("./parse_pi_log.cjs?" + Date.now()); parsePiLog = module.parsePiLog; transformPiEntries = module.transformPiEntries; + isPiV3Schema = module.isPiV3Schema; + transformPiV3Entries = module.transformPiV3Entries; + computePiV3Stats = module.computePiV3Stats; }); afterEach(() => { @@ -202,4 +205,98 @@ describe("parse_pi_log.cjs", () => { expect(entries).toHaveLength(0); }); }); + + describe("Pi v3 streaming schema", () => { + // A minimal but representative v3 stream: session init, one turn that calls a tool + // (with its tool_execution_end result emitted before the finalizing turn_end, as the + // real Pi CLI does), then a final turn with the closing assistant text. + const v3Lines = [ + { type: "session", version: 3, id: "sess-v3", timestamp: "2026-07-14T08:25:33.707Z", cwd: "/repo" }, + { type: "agent_start" }, + { type: "turn_start" }, + { + type: "tool_execution_start", + toolCallId: "call_1", + toolName: "bash", + args: { command: "ls" }, + }, + { + type: "tool_execution_end", + toolCallId: "call_1", + toolName: "bash", + result: { content: [{ type: "text", text: "file-a\nfile-b" }] }, + }, + { + type: "turn_end", + message: { + role: "assistant", + model: "gpt-5.4", + content: [ + { type: "text", text: "Let me list the files." }, + { type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } }, + ], + usage: { input: 1000, output: 40, totalTokens: 1040 }, + }, + }, + { type: "turn_end", message: { role: "assistant", model: "gpt-5.4", content: [{ type: "text", text: "All done." }], usage: { input: 1200, output: 15, totalTokens: 1215 } } }, + { type: "agent_end", messages: [] }, + ]; + const v3Log = v3Lines.map(l => JSON.stringify(l)).join("\n"); + + it("detects the v3 schema and rejects the legacy schema", () => { + expect(isPiV3Schema(v3Lines)).toBe(true); + expect(isPiV3Schema([{ type: "init", model: "pi-3" }, { type: "assistant", content: "hi" }])).toBe(false); + }); + + it("renders assistant text, tool calls, and tool results from a v3 stream", () => { + const result = parsePiLog(v3Log); + + expect(result.markdown).toContain("Let me list the files."); + expect(result.markdown).toContain("All done."); + expect(result.markdown).toContain("bash"); + expect(result.markdown).toContain("gpt-5.4"); + // The conversation must not be empty for a real v3 log. + expect(result.markdown.length).toBeGreaterThan(0); + }); + + it("emits each tool_use before its paired tool_result in a v3 turn", () => { + const entries = transformPiV3Entries(v3Lines); + const toolUseIdx = entries.findIndex(e => e.type === "assistant" && e.message.content[0].type === "tool_use" && e.message.content[0].id === "call_1"); + const toolResultIdx = entries.findIndex(e => e.type === "user" && e.message.content[0].type === "tool_result" && e.message.content[0].tool_use_id === "call_1"); + + expect(toolUseIdx).toBeGreaterThanOrEqual(0); + expect(toolResultIdx).toBeGreaterThan(toolUseIdx); + }); + + it("computes v3 stats: turns counted, output summed, input peaked", () => { + const stats = computePiV3Stats(v3Lines); + + expect(stats).not.toBeNull(); + expect(stats.turns).toBe(2); + expect(stats.output_tokens).toBe(55); // 40 + 15 + expect(stats.input_tokens).toBe(1200); // max(1000, 1200) + }); + + it("includes a normalized v3 result entry for OTEL enrichment", () => { + const result = parsePiLog(v3Log); + const resultEntry = result.logEntries && result.logEntries.find(e => e.type === "result"); + + expect(resultEntry).toBeDefined(); + expect(resultEntry.num_turns).toBe(2); + expect(resultEntry.usage).toEqual({ input_tokens: 1200, output_tokens: 55 }); + }); + + it("marks failed tool results as errors", () => { + const entries = transformPiV3Entries([ + { type: "session", version: 3, id: "s" }, + { type: "tool_execution_end", toolCallId: "t1", result: { status: "error", content: [{ type: "text", text: "boom" }] } }, + { type: "turn_end", message: { role: "assistant", model: "m", content: [{ type: "toolCall", id: "t1", name: "bash", arguments: {} }], usage: { input: 1, output: 1 } } }, + ]); + const toolResult = entries.find(e => e.type === "user" && e.message.content[0].type === "tool_result"); + + expect(toolResult).toBeDefined(); + expect(toolResult.message.content[0].is_error).toBe(true); + expect(toolResult.message.content[0].content).toContain("boom"); + }); + }); }); From afe34dece40270eb90283e8f4f2230c6e0497e39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:38:03 +0000 Subject: [PATCH 2/3] Plan: fix isError detection and input token summation in Pi v3 parser Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/skills/agentic-workflows/SKILL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/skills/agentic-workflows/SKILL.md b/.github/skills/agentic-workflows/SKILL.md index 72145f10bda..aee174f47cd 100644 --- a/.github/skills/agentic-workflows/SKILL.md +++ b/.github/skills/agentic-workflows/SKILL.md @@ -22,6 +22,7 @@ Load these files from `github/gh-aw` (they are not available locally). - `.github/aw/charts-trending.md` - `.github/aw/charts.md` - `.github/aw/cli-commands.md` +- `.github/aw/configure-agentic-engine.md` - `.github/aw/context.md` - `.github/aw/create-agentic-workflow-trigger-details.md` - `.github/aw/create-agentic-workflow.md` From 57eef80d2ab035c7e4a852e599a270abfaaa9706 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:42:46 +0000 Subject: [PATCH 3/3] Fix Pi v3 parser: event-level isError, sum input tokens, fix JSDoc glob syntax Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- actions/setup/js/parse_pi_log.cjs | 19 +++++++++---------- actions/setup/js/parse_pi_log.test.cjs | 8 ++++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/parse_pi_log.cjs b/actions/setup/js/parse_pi_log.cjs index 1c3da23ba04..920ead4f82a 100644 --- a/actions/setup/js/parse_pi_log.cjs +++ b/actions/setup/js/parse_pi_log.cjs @@ -62,7 +62,7 @@ function parsePiLog(logContent) { } // Pi CLI's `--mode json` output schema changed over time. Current builds emit a - // v3 streaming schema (session / turn_start / turn_end / tool_execution_* / agent_end) + // 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. @@ -189,7 +189,7 @@ function transformPiEntries(rawEntries) { /** * Detects whether the raw Pi entries use the v3 streaming schema. * - * The v3 schema emits envelope events (session/turn_end/tool_execution_*/agent_end) + * 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. * @@ -316,7 +316,7 @@ function transformPiV3Entries(rawEntries) { type: "tool_result", tool_use_id: id, content: extractPiV3ResultText(res.result), - is_error: isPiV3ResultError(res.result), + is_error: res.isError || isPiV3ResultError(res.result), }, ], }, @@ -360,9 +360,8 @@ function isPiV3ResultError(result) { /** * Computes aggregate stats from Pi v3 entries for the information section. * - * Each `turn_end` carries a cumulative-context `usage` object; output tokens are summed - * across turns while input (prompt) tokens use the maximum observed value, since the - * prompt context is resent each turn rather than accumulated. Turn count is the number of + * 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} rawEntries - Raw parsed JSONL entries @@ -370,7 +369,7 @@ function isPiV3ResultError(result) { */ function computePiV3Stats(rawEntries) { let outputTokens = 0; - let maxInputTokens = 0; + let inputTokens = 0; let turns = 0; let sawUsage = false; @@ -385,8 +384,8 @@ function computePiV3Stats(rawEntries) { if (typeof usage.output === "number") { outputTokens += usage.output; } - if (typeof usage.input === "number" && usage.input > maxInputTokens) { - maxInputTokens = usage.input; + if (typeof usage.input === "number") { + inputTokens += usage.input; } } } @@ -396,7 +395,7 @@ function computePiV3Stats(rawEntries) { } return { - input_tokens: maxInputTokens, + input_tokens: inputTokens, output_tokens: outputTokens, turns: turns, duration_ms: 0, diff --git a/actions/setup/js/parse_pi_log.test.cjs b/actions/setup/js/parse_pi_log.test.cjs index e85a83a477b..4011ea78bee 100644 --- a/actions/setup/js/parse_pi_log.test.cjs +++ b/actions/setup/js/parse_pi_log.test.cjs @@ -268,13 +268,13 @@ describe("parse_pi_log.cjs", () => { expect(toolResultIdx).toBeGreaterThan(toolUseIdx); }); - it("computes v3 stats: turns counted, output summed, input peaked", () => { + it("computes v3 stats: turns counted, output summed, input summed", () => { const stats = computePiV3Stats(v3Lines); expect(stats).not.toBeNull(); expect(stats.turns).toBe(2); expect(stats.output_tokens).toBe(55); // 40 + 15 - expect(stats.input_tokens).toBe(1200); // max(1000, 1200) + expect(stats.input_tokens).toBe(2200); // 1000 + 1200 }); it("includes a normalized v3 result entry for OTEL enrichment", () => { @@ -283,13 +283,13 @@ describe("parse_pi_log.cjs", () => { expect(resultEntry).toBeDefined(); expect(resultEntry.num_turns).toBe(2); - expect(resultEntry.usage).toEqual({ input_tokens: 1200, output_tokens: 55 }); + expect(resultEntry.usage).toEqual({ input_tokens: 2200, output_tokens: 55 }); }); it("marks failed tool results as errors", () => { const entries = transformPiV3Entries([ { type: "session", version: 3, id: "s" }, - { type: "tool_execution_end", toolCallId: "t1", result: { status: "error", content: [{ type: "text", text: "boom" }] } }, + { type: "tool_execution_end", toolCallId: "t1", isError: true, result: { content: [{ type: "text", text: "boom" }] } }, { type: "turn_end", message: { role: "assistant", model: "m", content: [{ type: "toolCall", id: "t1", name: "bash", arguments: {} }], usage: { input: 1, output: 1 } } }, ]); const toolResult = entries.find(e => e.type === "user" && e.message.content[0].type === "tool_result");