diff --git a/plugins/warp/scripts/extract-transcript.sh b/plugins/warp/scripts/extract-transcript.sh new file mode 100644 index 0000000..a054e9b --- /dev/null +++ b/plugins/warp/scripts/extract-transcript.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Shared helpers for scraping the Claude Code session transcript. +# +# The Stop and StopFailure hooks send the user's last prompt to Warp, which +# renders it as the notification title. A transcript's `type: "user"` entries +# are not all human prompts though: Claude Code also injects local `!` bash +# output, slash-command wrappers, background subagent completions, +# cross-session messages and system reminders as user-type entries with +# ordinary text content. Without filtering, those envelopes become the title — +# e.g. a completion toast reading `arn:aws:ecs:us-east-1:...` +# instead of what the user actually asked for. +# +# The filter only skips patterns it positively recognizes; anything else is +# passed through untouched. An odd-looking title beats a missing notification. + +# Envelope tags for entries Claude Code injects on its own. These are stable +# ASCII identifiers, matched at the start of the entry's text so that a human +# prompt merely quoting a tag is left alone. +WARP_SYNTHETIC_TAGS='bash-input|bash-stdout|bash-stderr' +WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|command-args|command-message|command-name" +WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|local-command-caveat|local-command-stderr|local-command-stdout" +WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|persisted-output|system-reminder" +WARP_SYNTHETIC_TAGS="$WARP_SYNTHETIC_TAGS|task-id|task-notification|user-prompt-submit-hook" + +# Cross-session and teammate envelopes are preceded by a framing line +# ("Another Claude session sent a message:"), so their tag is not at the start +# of the entry. The framing line is human language and may be localized, so +# anchor on the tag itself and accept it anywhere in the entry. +WARP_SYNTHETIC_TAGS_ANYWHERE='cross-session-message|teammate-message' + +# Markers Claude Code injects without an XML envelope. English-only, so this is +# best effort: a localized build falls through to today's behaviour rather than +# breaking. +WARP_SYNTHETIC_PREFIXES='\[Request interrupted by user|\[SYSTEM NOTIFICATION|Stop hook feedback:' + +# Prints the most recent human prompt in the transcript, or nothing when the +# transcript is unreadable or holds no human prompt. +extract_last_user_prompt() { + local transcript_path="${1:-}" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + return 0 + fi + + jq -rs \ + --arg tags "$WARP_SYNTHETIC_TAGS" \ + --arg tags_anywhere "$WARP_SYNTHETIC_TAGS_ANYWHERE" \ + --arg prefixes "$WARP_SYNTHETIC_PREFIXES" ' + [ + .[] | select(.type == "user") | + # Human prompts are a plain string or an array with text blocks; + # tool results are arrays of tool_result blocks and drop out here. + (if .message.content | type == "string" then .message.content + elif .message.content | type == "array" then + ([.message.content[]? | select(.type == "text") | .text] | join(" ")) + else empty + end) as $text | + select(($text | length) > 0) | + select($text | test("^\\s*<(" + $tags + ")\\b") | not) | + select($text | test("<(" + $tags_anywhere + ")\\b") | not) | + select($text | test("^\\s*(" + $prefixes + ")") | not) | + $text + ] | last // empty + ' "$transcript_path" 2>/dev/null +} + +# Prints the text of the most recent assistant message in the transcript, or +# nothing when the transcript is unreadable or holds no assistant message. +extract_last_assistant_response() { + local transcript_path="${1:-}" + + if [ -z "$transcript_path" ] || [ ! -f "$transcript_path" ]; then + return 0 + fi + + jq -rs ' + [.[] | select(.type == "assistant" and .message.content)] | last | + [.message.content[] | select(.type == "text") | .text] | join(" ") + ' "$transcript_path" 2>/dev/null +} diff --git a/plugins/warp/scripts/on-stop-failure.sh b/plugins/warp/scripts/on-stop-failure.sh index b170326..bc7eea5 100755 --- a/plugins/warp/scripts/on-stop-failure.sh +++ b/plugins/warp/scripts/on-stop-failure.sh @@ -12,6 +12,7 @@ if ! should_use_structured; then fi source "$SCRIPT_DIR/build-payload.sh" +source "$SCRIPT_DIR/extract-transcript.sh" # Read hook input from stdin INPUT=$(cat) @@ -25,19 +26,7 @@ ERROR_MESSAGE=$(echo "$INPUT" | jq -r '.last_assistant_message // empty' 2>/dev/ TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) QUERY="" if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then - QUERY=$(jq -rs ' - [ - .[] | select(.type == "user") | - if .message.content | type == "string" then . - elif [.message.content[] | select(.type == "text")] | length > 0 then . - else empty - end - ] | last | - if .message.content | type == "array" - then [.message.content[] | select(.type == "text") | .text] | join(" ") - else .message.content // empty - end - ' "$TRANSCRIPT_PATH" 2>/dev/null) + QUERY=$(extract_last_user_prompt "$TRANSCRIPT_PATH") if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then QUERY="${QUERY:0:197}..." diff --git a/plugins/warp/scripts/on-stop.sh b/plugins/warp/scripts/on-stop.sh index 4163bb9..7c2e938 100755 --- a/plugins/warp/scripts/on-stop.sh +++ b/plugins/warp/scripts/on-stop.sh @@ -12,6 +12,7 @@ if ! should_use_structured; then fi source "$SCRIPT_DIR/build-payload.sh" +source "$SCRIPT_DIR/extract-transcript.sh" # Read hook input from stdin INPUT=$(cat) @@ -30,31 +31,8 @@ sleep 0.3 QUERY="" RESPONSE="" if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then - # Get the last human prompt from the transcript. - # "user" type messages include both human prompts and tool-result messages. - # Human prompts have content that is either a plain string or an array - # containing {type:"text"} blocks. Tool-result messages have content arrays - # containing only {type:"tool_result"} blocks. We filter to messages that - # have at least one "text" block (or are a plain string). - QUERY=$(jq -rs ' - [ - .[] | select(.type == "user") | - if .message.content | type == "string" then . - elif [.message.content[] | select(.type == "text")] | length > 0 then . - else empty - end - ] | last | - if .message.content | type == "array" - then [.message.content[] | select(.type == "text") | .text] | join(" ") - else .message.content // empty - end - ' "$TRANSCRIPT_PATH" 2>/dev/null) - - # Get the last assistant response - RESPONSE=$(jq -rs ' - [.[] | select(.type == "assistant" and .message.content)] | last | - [.message.content[] | select(.type == "text") | .text] | join(" ") - ' "$TRANSCRIPT_PATH" 2>/dev/null) + QUERY=$(extract_last_user_prompt "$TRANSCRIPT_PATH") + RESPONSE=$(extract_last_assistant_response "$TRANSCRIPT_PATH") # Truncate for notification display if [ -n "$QUERY" ] && [ ${#QUERY} -gt 200 ]; then diff --git a/plugins/warp/tests/test-hooks.sh b/plugins/warp/tests/test-hooks.sh index 754bdd0..5701b76 100755 --- a/plugins/warp/tests/test-hooks.sh +++ b/plugins/warp/tests/test-hooks.sh @@ -287,6 +287,102 @@ for HOOK in on-permission-request.sh on-prompt-submit.sh on-post-tool-use.sh; do assert_eq "$HOOK exits 0 without protocol version" "0" "$?" done +echo "" +echo "=== extract-transcript.sh ===" + +source "$SCRIPT_DIR/extract-transcript.sh" + +TRANSCRIPT=$(mktemp -t warp-transcript) +trap 'rm -f "$TRANSCRIPT"' EXIT + +# Writes each argument as one JSONL entry of the mock transcript. +write_transcript() { + printf '%s\n' "$@" > "$TRANSCRIPT" +} + +HUMAN='{"type":"user","message":{"role":"user","content":"check the prod deploy"}}' +HUMAN_OLDER='{"type":"user","message":{"role":"user","content":"open the dashboard"}}' +ASSISTANT='{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking."}]}}' +TOOL_RESULT='{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]}}' + +echo "" +echo "--- Human prompts are returned ---" + +write_transcript "$HUMAN_OLDER" "$ASSISTANT" "$HUMAN" "$ASSISTANT" +assert_eq "last human prompt wins" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript '{"type":"user","message":{"role":"user","content":[{"type":"text","text":"deploy"},{"type":"text","text":"now"}]}}' +assert_eq "text blocks are joined" "deploy now" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript "$HUMAN" "$TOOL_RESULT" +assert_eq "tool results are skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +echo "" +echo "--- Synthetic entries are skipped ---" + +# Local `!` bash mode: Claude Code records the command and its output as +# plain-string user entries. +write_transcript "$HUMAN" \ + '{"type":"user","message":{"role":"user","content":"aws ecs list-tasks"}}' \ + '{"type":"user","message":{"role":"user","content":"arn:aws:ecs:us-east-1:123:task/prod/a4d9c84a"}}' +assert_eq "bash mode output is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript "$HUMAN" \ + '{"type":"user","message":{"role":"user","content":"\nb7s43w48d\n"}}' +assert_eq "background subagent notification is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript "$HUMAN" \ + '{"type":"user","message":{"role":"user","content":"/clearclear"}}' \ + '{"type":"user","message":{"role":"user","content":"Caveat"}}' \ + '{"type":"user","message":{"role":"user","content":"Login successful."}}' +assert_eq "slash-command wrappers are skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript "$HUMAN" \ + '{"type":"user","message":{"role":"user","content":"Do not mention this."}}' +assert_eq "system reminder is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +# Cross-session/teammate envelopes arrive behind a framing line, so the tag is +# not at the start of the entry. +write_transcript "$HUMAN" \ + '{"type":"user","message":{"role":"user","content":"Another Claude session sent a message:\nok"}}' +assert_eq "teammate message is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript "$HUMAN" \ + '{"type":"user","message":{"role":"user","content":"[Request interrupted by user for tool use]"}}' +assert_eq "interrupt marker is skipped" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript "$HUMAN" \ + '{"type":"user","message":{"role":"user","content":" \nindented"}}' +assert_eq "leading whitespace does not defeat the filter" "check the prod deploy" "$(extract_last_user_prompt "$TRANSCRIPT")" + +echo "" +echo "--- Human prompts are never over-filtered ---" + +write_transcript '{"type":"user","message":{"role":"user","content":"why does the title show instead of my prompt?"}}' +assert_eq "a prompt mentioning a tag mid-sentence is kept" \ + "why does the title show instead of my prompt?" \ + "$(extract_last_user_prompt "$TRANSCRIPT")" + +echo "" +echo "--- Degenerate transcripts ---" + +write_transcript '{"type":"user","message":{"role":"user","content":"only synthetic"}}' +assert_eq "all-synthetic transcript yields empty (Warp falls back to its own title)" \ + "" "$(extract_last_user_prompt "$TRANSCRIPT")" + +write_transcript "$ASSISTANT" +assert_eq "no user entries yields empty" "" "$(extract_last_user_prompt "$TRANSCRIPT")" + +assert_eq "missing transcript yields empty" "" "$(extract_last_user_prompt "/nonexistent/transcript.jsonl")" +assert_eq "empty path yields empty" "" "$(extract_last_user_prompt "")" + +echo "" +echo "--- Assistant response extraction ---" + +write_transcript "$HUMAN" "$ASSISTANT" +assert_eq "last assistant response returned" "Looking." "$(extract_last_assistant_response "$TRANSCRIPT")" +assert_eq "missing transcript yields empty response" "" "$(extract_last_assistant_response "/nonexistent/transcript.jsonl")" + # --- Summary --- echo ""