From 67f4a7591e041c6199eff8ff3766e3ac9b610634 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 14:24:06 +0000 Subject: [PATCH] fix(actions): extract agent text from execution file instead of using its path claude-code-action@v1.x exposes only execution_file (a path to a JSONL transcript); claude-harness re-exports it as the deprecated `result` alias. Five pre-v1 callers (flag-audit, agent-test, agent-test-gen, create-release, eval-smoke) consumed `result` as if it were Claude's text output, so they emitted the raw path. The visible symptom was flag-audit filing issues whose body was literally "/home/runner/_work/_temp/claude-execution-output.json" (#177). Add a shared _common/extract-text action that reads the execution file and recovers the agent's final text (result record -> last assistant text blocks -> single-object .result -> plain-text passthrough), and wire it into all five callers. Keep the `result` alias for any unmigrated consumers. Closes #177 --- actions/_common/claude-harness/action.yml | 5 +- actions/_common/extract-text/action.yml | 41 +++++++ actions/_common/extract-text/extract-text.sh | 70 +++++++++++ actions/_common/flag-audit/action.yml | 10 +- actions/ci/eval-smoke/action.yml | 9 +- actions/pr/agent-test-gen/action.yml | 10 +- actions/prd/create-release/action.yml | 11 +- actions/stg/agent-test/action.yml | 10 +- tests/actions/extract-text.bats | 121 +++++++++++++++++++ 9 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 actions/_common/extract-text/action.yml create mode 100755 actions/_common/extract-text/extract-text.sh create mode 100644 tests/actions/extract-text.bats diff --git a/actions/_common/claude-harness/action.yml b/actions/_common/claude-harness/action.yml index 18beb7f..bd1d086 100644 --- a/actions/_common/claude-harness/action.yml +++ b/actions/_common/claude-harness/action.yml @@ -228,9 +228,10 @@ outputs: # `result` expecting Claude's text output. claude-code-action@v1.x has no # such output — `execution_file` is a path to a JSON transcript. We expose # `result` as an alias so those callers keep working at the type level - # while we migrate them. + # while we migrate them. Migrated callers should instead feed `execution-file` + # to _common/extract-text to recover the agent's text (see #177). result: - description: "[deprecated] Alias of execution-file. Pre-v1 callers expected text; v1 returns a path." + description: "[deprecated] Alias of execution-file. Pre-v1 callers expected text; v1 returns a path. Use _common/extract-text on execution-file." value: ${{ steps.invoke.outputs.execution_file }} comment-id: description: "[deprecated] No longer populated. claude-code-action@v1.x uses sticky comments managed internally." diff --git a/actions/_common/extract-text/action.yml b/actions/_common/extract-text/action.yml new file mode 100644 index 0000000..436644a --- /dev/null +++ b/actions/_common/extract-text/action.yml @@ -0,0 +1,41 @@ +# ───────────────────────────────────────────────────────────────────────────── +# _common/extract-text — pull the final assistant text out of a claude-harness +# execution file. +# ───────────────────────────────────────────────────────────────────────────── +# claude-code-action@v1.x writes a JSONL transcript to a file; its path is +# exposed by claude-harness as `execution-file` (and the deprecated `result` +# alias). Pre-v1 callers that wanted Claude's *text* output — flag-audit, +# agent-test, eval-smoke, agent-test-gen, create-release — were reading that +# path and treating it as the text itself, producing bodies that were literally +# "/home/runner/_work/_temp/claude-execution-output.json" (see #177). +# +# This helper reads the transcript and returns the agent's final text so those +# callers can keep their "result is text" contract on top of v1.x. +# ───────────────────────────────────────────────────────────────────────────── +name: Extract Agent Text +description: Extract the final assistant text from a claude-harness execution file. + +inputs: + execution-file: + description: Path to the claude-harness execution output file. + required: false + default: "" + skipped: + description: '"true" when the agent was skipped (e.g. API key absent). Emits empty text.' + required: false + default: "false" + +outputs: + text: + description: The agent's final text output (empty when missing/skipped/unparseable). + value: ${{ steps.run.outputs.text }} + +runs: + using: composite + steps: + - id: run + shell: bash + env: + EXECUTION_FILE: ${{ inputs.execution-file }} + SKIPPED: ${{ inputs.skipped }} + run: bash "${{ github.action_path }}/extract-text.sh" diff --git a/actions/_common/extract-text/extract-text.sh b/actions/_common/extract-text/extract-text.sh new file mode 100755 index 0000000..1863eba --- /dev/null +++ b/actions/_common/extract-text/extract-text.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Extracts the agent's final text output from a claude-harness execution file. +# +# Handles the formats claude-code-action@v1.x (and older builds) can emit: +# 1. JSONL transcript: system/init … assistant … result/success records. +# The final text lives in the result record's `.result` string, or +# failing that the last assistant message's text content blocks. +# 2. Single JSON object (legacy direct-skill output) carrying `.result` +# or assistant-style `.content[].text`. +# 3. Plain text (very old single-string output) — passed through verbatim. +# +# Emits `text=<...>` (heredoc form) on $GITHUB_OUTPUT. On any failure to find +# text — missing file, skipped agent, unparseable transcript — emits empty +# text and exits 0 so callers can decide how to degrade. +set -euo pipefail + +emit() { + # $1 = text payload (may be empty / multiline) + { + echo "text<<__OPENCI_TEXT_EOF__" + printf '%s\n' "$1" + echo "__OPENCI_TEXT_EOF__" + } >> "$GITHUB_OUTPUT" +} + +if [ "${SKIPPED:-false}" = "true" ]; then + emit "" + exit 0 +fi + +file="${EXECUTION_FILE:-}" +if [ -z "$file" ] || [ ! -f "$file" ]; then + emit "" + exit 0 +fi + +text="" + +# Strategy A — result record's `.result` text (claude-code-action success). +# Slurp so both JSONL streams and single JSON arrays/objects parse as one doc; +# recursive descent finds the result record wherever it sits. +text="$(jq -sr ' + [.. | objects | select(.type? == "result") | .result?] + | map(select(type == "string" and length > 0)) | last // empty +' "$file" 2>/dev/null || true)" + +# Strategy B — last assistant message's concatenated text content blocks. +if [ -z "$text" ]; then + text="$(jq -sr ' + [ .. | objects + | select(.type? == "assistant") + | .message?.content? // .content? + | arrays | map(select(.type? == "text") | .text?) | join("") + ] + | map(select(type == "string" and length > 0)) | last // empty + ' "$file" 2>/dev/null || true)" +fi + +# Strategy C — single JSON object that itself carries `.result`. +if [ -z "$text" ]; then + text="$(jq -r 'select(type == "object") | .result? // empty | strings' \ + "$file" 2>/dev/null | head -n1 || true)" +fi + +# Strategy D — file is plain text (not JSON at all): pass it through verbatim. +if [ -z "$text" ] && ! jq -e . "$file" >/dev/null 2>&1; then + text="$(cat "$file")" +fi + +emit "$text" diff --git a/actions/_common/flag-audit/action.yml b/actions/_common/flag-audit/action.yml index 6a189da..99e48e8 100644 --- a/actions/_common/flag-audit/action.yml +++ b/actions/_common/flag-audit/action.yml @@ -75,12 +75,20 @@ runs: model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }} github-token: ${{ inputs.github-token }} + # claude-harness `result` is a PATH to the JSONL transcript, not text. + # Extract the agent's final report before filing the issue (see #177). + - name: Extract audit report + id: report + uses: ./.openci/actions/_common/extract-text + with: + execution-file: ${{ steps.harness.outputs.execution-file }} + - name: File audit issue shell: bash env: GH_TOKEN: ${{ inputs.github-token }} REPO: ${{ github.repository }} - BODY: ${{ steps.harness.outputs.result }} + BODY: ${{ steps.report.outputs.text }} run: | set -euo pipefail if [ -z "$BODY" ]; then diff --git a/actions/ci/eval-smoke/action.yml b/actions/ci/eval-smoke/action.yml index f107b23..c037a7d 100644 --- a/actions/ci/eval-smoke/action.yml +++ b/actions/ci/eval-smoke/action.yml @@ -27,7 +27,7 @@ inputs: outputs: result: description: AI eval report (JSON-encoded text). - value: ${{ steps.harness.outputs.result }} + value: ${{ steps.report.outputs.text }} api-base-url: required: false @@ -67,3 +67,10 @@ runs: api-key: ${{ inputs.anthropic-api-key }} api-base-url: ${{ inputs.api-base-url }} model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }} + + # `result` is a transcript PATH under claude-code-action@v1.x; extract the + # eval report text so downstream `outputs.result` is the report, not a path. + - id: report + uses: ./.openci/actions/_common/extract-text + with: + execution-file: ${{ steps.harness.outputs.execution-file }} diff --git a/actions/pr/agent-test-gen/action.yml b/actions/pr/agent-test-gen/action.yml index 1bd2ba9..671f4bc 100644 --- a/actions/pr/agent-test-gen/action.yml +++ b/actions/pr/agent-test-gen/action.yml @@ -73,10 +73,18 @@ runs: model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }} github-token: ${{ inputs.github-token }} + # `result` is a transcript PATH under claude-code-action@v1.x; extract the + # agent's generated output so the staged file holds it, not a path. + - name: Extract generated output + id: gen + uses: ./.openci/actions/_common/extract-text + with: + execution-file: ${{ steps.harness.outputs.execution-file }} + - name: Stage generated files (no auto-commit) shell: bash env: - OUTPUT: ${{ steps.harness.outputs.result }} + OUTPUT: ${{ steps.gen.outputs.text }} run: | set -euo pipefail mkdir -p triage/test-gen diff --git a/actions/prd/create-release/action.yml b/actions/prd/create-release/action.yml index 6856ded..3ce1c3e 100644 --- a/actions/prd/create-release/action.yml +++ b/actions/prd/create-release/action.yml @@ -103,13 +103,22 @@ runs: model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }} github-token: ${{ inputs.github-token }} + # `result` is a transcript PATH under claude-code-action@v1.x; extract the + # changelog text so release notes hold the body, not a path. + - name: Extract changelog + id: notes + if: steps.skip.outputs.skip != 'true' && inputs.use-ai-changelog == 'true' && inputs.anthropic-api-key != '' + uses: ./.openci/actions/_common/extract-text + with: + execution-file: ${{ steps.ai.outputs.execution-file }} + - name: Create release if: steps.skip.outputs.skip != 'true' shell: bash env: GH_TOKEN: ${{ inputs.github-token }} VER: ${{ steps.ver.outputs.version }} - AI_BODY: ${{ steps.ai.outputs.result }} + AI_BODY: ${{ steps.notes.outputs.text }} run: | set -euo pipefail if [ -n "$AI_BODY" ]; then diff --git a/actions/stg/agent-test/action.yml b/actions/stg/agent-test/action.yml index fe4bec4..5712f3d 100644 --- a/actions/stg/agent-test/action.yml +++ b/actions/stg/agent-test/action.yml @@ -56,11 +56,19 @@ runs: api-base-url: ${{ inputs.api-base-url }} model: ${{ inputs.model || 'claude-sonnet-4-5-20250929' }} + # `result` is a transcript PATH under claude-code-action@v1.x; extract the + # agent's finding text so the staged file holds the report, not a path. + - name: Extract findings + id: findings + uses: ./.openci/actions/_common/extract-text + with: + execution-file: ${{ steps.harness.outputs.execution-file }} + - name: Stage findings shell: bash env: L: ${{ inputs.level }} - OUTPUT: ${{ steps.harness.outputs.result }} + OUTPUT: ${{ steps.findings.outputs.text }} run: | mkdir -p triage/agent-test printf '%s\n' "$OUTPUT" > "triage/agent-test/L${L}.json" diff --git a/tests/actions/extract-text.bats b/tests/actions/extract-text.bats new file mode 100644 index 0000000..e4865ac --- /dev/null +++ b/tests/actions/extract-text.bats @@ -0,0 +1,121 @@ +#!/usr/bin/env bats +# Tests for actions/_common/extract-text/extract-text.sh + +bats_require_minimum_version 1.5.0 + +setup() { + PROJECT_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)" + SCRIPT="${PROJECT_ROOT}/actions/_common/extract-text/extract-text.sh" + TMPDIR="$(mktemp -d)" + export GITHUB_OUTPUT="${TMPDIR}/output.txt" + touch "$GITHUB_OUTPUT" +} + +teardown() { + rm -rf "$TMPDIR" +} + +run_extract() { + SKIPPED="${1:-false}" EXECUTION_FILE="${2:-}" run bash "$SCRIPT" +} + +# Returns everything between the text<"$ef" <<'JSONL' +{"type":"system","subtype":"init","message":"Claude Code initialized"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"working"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"## TL;DR\nAll clear."} +JSONL + run_extract "false" "$ef" + [ "$status" -eq 0 ] + [ "$(get_text)" = "$(printf '## TL;DR\nAll clear.')" ] +} + +@test "JSONL: falls back to last assistant text when no result field" { + local ef="${TMPDIR}/exec.jsonl" + cat >"$ef" <<'JSONL' +{"type":"system","subtype":"init","message":"Claude Code initialized"} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"first"}]}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"final report"}]}} +{"type":"result","subtype":"success","is_error":false} +JSONL + run_extract "false" "$ef" + [ "$status" -eq 0 ] + [ "$(get_text)" = "final report" ] +} + +@test "JSONL: multiple text blocks in one assistant message are concatenated" { + local ef="${TMPDIR}/exec.jsonl" + cat >"$ef" <<'JSONL' +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"part one "},{"type":"text","text":"part two"}]}} +{"type":"result","subtype":"success","is_error":false} +JSONL + run_extract "false" "$ef" + [ "$status" -eq 0 ] + [ "$(get_text)" = "part one part two" ] +} + +@test "single JSON object with .result is extracted" { + local ef="${TMPDIR}/exec.json" + echo '{"result":"single object report"}' > "$ef" + run_extract "false" "$ef" + [ "$status" -eq 0 ] + [ "$(get_text)" = "single object report" ] +} + +@test "plain text file is passed through verbatim" { + local ef="${TMPDIR}/exec.txt" + printf 'just some markdown\nsecond line\n' > "$ef" + run_extract "false" "$ef" + [ "$status" -eq 0 ] + [ "$(get_text)" = "$(printf 'just some markdown\nsecond line')" ] +} + +@test "result record is preferred over earlier assistant text" { + local ef="${TMPDIR}/exec.jsonl" + cat >"$ef" <<'JSONL' +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"intermediate"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"authoritative"} +JSONL + run_extract "false" "$ef" + [ "$status" -eq 0 ] + [ "$(get_text)" = "authoritative" ] +} + +@test "transcript with no text anywhere emits empty text" { + local ef="${TMPDIR}/exec.jsonl" + cat >"$ef" <<'JSONL' +{"type":"system","subtype":"init","message":"Claude Code initialized"} +{"type":"result","subtype":"success","is_error":false,"duration_ms":1234} +JSONL + run_extract "false" "$ef" + [ "$status" -eq 0 ] + [ -z "$(get_text)" ] +}