From d0b076a4ccae3d1cbc927c047e575c903090352c Mon Sep 17 00:00:00 2001 From: Daniil Zinenko Date: Tue, 11 Aug 2026 11:48:54 +0200 Subject: [PATCH] fix(warp): skip synthetic transcript entries when picking the notification title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop and StopFailure hooks title Warp's notification with "the user's last prompt", which they read from the session transcript. The filter only excluded tool-result entries, on the assumption that every other `type: "user"` entry is human-authored. Claude Code also writes its own injections as user-type entries with ordinary text content, so those became the title: arn:aws:ecs:us-east-1:123:task/prod-cluster/a4d9c84a37… Task completed. Local `!` bash output is the most visible case, but the same class covers background subagent completions, slash-command wrappers, local command output, system reminders and cross-session/teammate messages. Move the transcript scraping into a sourceable extract-transcript.sh — which also drops the copy of the filter that on-stop-failure.sh had to duplicate — and skip entries that are confidently recognized as Claude Code internals, falling back to the previous real prompt. Detection anchors on the ASCII envelope tags rather than the surrounding English framing, which localized builds may translate. Anything not positively recognized passes through unchanged: an odd-looking title is better than a missing notification. Verified against a real transcript that reproduced the screenshot above: the title becomes the user's actual prompt instead of the ARN dump. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_013Q7WdVu4eYxgDbEweugNDT --- plugins/warp/scripts/extract-transcript.sh | 80 ++++++++++++++++++ plugins/warp/scripts/on-stop-failure.sh | 15 +--- plugins/warp/scripts/on-stop.sh | 28 +------ plugins/warp/tests/test-hooks.sh | 96 ++++++++++++++++++++++ 4 files changed, 181 insertions(+), 38 deletions(-) create mode 100644 plugins/warp/scripts/extract-transcript.sh 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 ""