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` diff --git a/actions/setup/js/parse_pi_log.cjs b/actions/setup/js/parse_pi_log.cjs index d0edb48fd6f..920ead4f82a 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_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} 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: 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} 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) { + return null; + } + + 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} 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, }; } diff --git a/actions/setup/js/parse_pi_log.test.cjs b/actions/setup/js/parse_pi_log.test.cjs index 6108362b573..4011ea78bee 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 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(2200); // 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: 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", 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"); + + expect(toolResult).toBeDefined(); + expect(toolResult.message.content[0].is_error).toBe(true); + expect(toolResult.message.content[0].content).toContain("boom"); + }); + }); });