Skip to content

Commit 97b6d56

Browse files
authored
Merge pull request #3 from TimJJTing/fix/stop-transcript-format
Fix: Stop hook speech bubble delayed by one turn
2 parents d3796f1 + 9ee7d72 commit 97b6d56

3 files changed

Lines changed: 23 additions & 53 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "claudesay",
3-
"version": "2.0.2",
3+
"version": "2.0.3",
44
"description": "Renders Claude replies as ASCII character speech bubbles",
55
"author": {
66
"name": "TimJJTing"

hooks/scripts/stop.sh

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env bash
22
set -euo pipefail
33

4+
INPUT=$(cat)
5+
46
FLAG="${CLAUDE_PROJECT_DIR}/.claude/.claudesay-active"
57
[[ -f "$FLAG" ]] || { printf '{"decision":"approve"}\n'; exit 0; }
68

@@ -9,37 +11,18 @@ if ! command -v jq &>/dev/null; then
911
exit 0
1012
fi
1113

12-
INPUT=$(cat)
13-
TRANSCRIPT=$(printf '%s' "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null || true)
14-
15-
if [[ -z "$TRANSCRIPT" || ! -f "$TRANSCRIPT" ]]; then
16-
printf '{"decision":"approve"}\n'
17-
exit 0
18-
fi
19-
20-
# Extract the last assistant text block from the JSONL transcript.
21-
# Claude Code transcripts wrap each turn under a "message" key:
22-
# {"message": {"role": "assistant", "content": [...]}}
23-
LAST_MSG=$(jq -rs '
24-
map(select(.message.role == "assistant"))
25-
| if length == 0 then ""
26-
else last
27-
| .message.content
28-
| if type == "array" then
29-
map(select(.type == "text") | .text) | join("")
30-
elif type == "string" then .
31-
else ""
32-
end
33-
end
34-
' "$TRANSCRIPT" 2>/dev/null || true)
14+
# Use response_preview (current turn, first 500 chars) instead of transcript.
15+
# The transcript is written AFTER the Stop hook returns, so transcript-based
16+
# extraction is always one turn behind.
17+
RESPONSE=$(printf '%s' "$INPUT" | jq -r '.response_preview // empty' 2>/dev/null || true)
3518

36-
if [[ -z "$LAST_MSG" ]]; then
19+
if [[ -z "$RESPONSE" ]]; then
3720
printf '{"decision":"approve"}\n'
3821
exit 0
3922
fi
4023

4124
# Extract the last <claudesay> tag (POSIX grep -o, no -P needed).
42-
TAG=$(printf '%s' "$LAST_MSG" \
25+
TAG=$(printf '%s' "$RESPONSE" \
4326
| grep -o '<claudesay mood="[^"]*">[^<]*</claudesay>' \
4427
| tail -1 || true)
4528

@@ -49,9 +32,6 @@ if [[ -n "$TAG" ]]; then
4932

5033
RENDER="${CLAUDE_PLUGIN_ROOT}/lib/render.sh"
5134
if [[ -f "$RENDER" ]]; then
52-
# Capture render into temp file so we can emit as systemMessage.
53-
# Writing to /dev/tty gets clobbered when Claude Code's TUI redraws its
54-
# dynamic region; systemMessage lands in permanent scrollback instead.
5535
TMP=$(mktemp)
5636
CLAUDE_SAY_TTY="$TMP" bash "$RENDER" "$MSG" "$MOOD" 2>/dev/null || true
5737
BUBBLE=$(cat "$TMP" 2>/dev/null || true)

tests/test-hooks.sh

Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,59 +11,49 @@ mkdir -p "${CLAUDE_PROJECT_DIR}/.claude"
1111
FLAG="${CLAUDE_PROJECT_DIR}/.claude/.claudesay-active"
1212
trap 'rm -f "$TTY_FILE"; rm -rf "$CLAUDE_PROJECT_DIR"' EXIT
1313

14-
# Helper: write a transcript in the Claude Code JSONL format.
15-
# Each entry wraps the message under a "message" key, matching what
16-
# ~/.claude/projects/<proj>/<session>.jsonl actually contains.
17-
make_transcript() {
18-
local text="$1"
19-
local tmp; tmp=$(mktemp)
20-
jq -n '{"type":"user","message":{"role":"user","content":"hello"}}' >> "$tmp"
21-
jq -n --arg t "$text" \
22-
'{"message":{"role":"assistant","content":[{"type":"text","text":$t}]}}' >> "$tmp"
23-
echo "$tmp"
24-
}
25-
14+
# run_stop passes a response_preview string directly, matching the real Stop
15+
# hook input. Using response_preview avoids the one-turn delay caused by the
16+
# transcript being written AFTER the hook returns.
2617
run_stop() {
27-
local transcript="$1"
28-
printf '{"transcript_path":"%s"}\n' "$transcript" \
18+
local preview="$1"
19+
jq -n --arg p "$preview" '{"response_preview":$p}' \
2920
| bash "$PLUGIN_ROOT/hooks/scripts/stop.sh"
3021
}
3122

3223
echo "=== stop.sh: flag absent → silent approve ==="
3324
rm -f "$FLAG"
34-
out=$(run_stop /dev/null)
25+
out=$(run_stop "hello <claudesay mood=\"happy\">hi</claudesay>")
3526
assert_eq "returns approve when flag absent" "$out" '{"decision":"approve"}'
3627
assert_eq "no tty output when flag absent" "$(cat "$TTY_FILE")" ""
3728

3829
echo ""
3930
echo "=== stop.sh: flag present, tag found → emits systemMessage bubble ==="
4031
mkdir -p "$(dirname "$FLAG")"; touch "$FLAG"
41-
TRANSCRIPT=$(make_transcript 'Great job! <claudesay mood="excited">All 3 tests pass!</claudesay>')
42-
out=$(run_stop "$TRANSCRIPT")
32+
out=$(run_stop 'Great job! <claudesay mood="excited">All 3 tests pass!</claudesay>')
4333
assert_contains "returns approve decision" "$out" '"decision": "approve"'
4434
assert_contains "emits systemMessage key" "$out" '"systemMessage"'
4535
parsed=$(printf '%s' "$out" | jq -r '.systemMessage' 2>/dev/null || true)
4636
assert_contains "bubble content in systemMessage" "$parsed" "All 3 tests pass!"
4737
assert_eq "no direct tty write" "$(cat "$TTY_FILE")" ""
4838
> "$TTY_FILE"
49-
rm -f "$TRANSCRIPT"
5039

5140
echo ""
5241
echo "=== stop.sh: flag present, no tag → silent approve ==="
53-
TRANSCRIPT=$(make_transcript 'Here is some code without a tag')
54-
out=$(run_stop "$TRANSCRIPT")
42+
out=$(run_stop 'Here is some code without a tag')
5543
assert_eq "returns approve when no tag" "$out" '{"decision":"approve"}'
5644
assert_eq "no tty output when no tag" "$(cat "$TTY_FILE")" ""
57-
rm -f "$TRANSCRIPT"
5845

5946
echo ""
6047
echo "=== stop.sh: multiple tags → uses last one ==="
61-
TRANSCRIPT=$(make_transcript 'First <claudesay mood="happy">first msg</claudesay> then <claudesay mood="excited">second msg</claudesay>')
62-
out=$(run_stop "$TRANSCRIPT")
48+
out=$(run_stop 'First <claudesay mood="happy">first msg</claudesay> then <claudesay mood="excited">second msg</claudesay>')
6349
parsed=$(printf '%s' "$out" | jq -r '.systemMessage' 2>/dev/null || true)
6450
assert_contains "last tag wins" "$parsed" "second msg"
6551
> "$TTY_FILE"
66-
rm -f "$TRANSCRIPT"
52+
53+
echo ""
54+
echo "=== stop.sh: empty response_preview → silent approve ==="
55+
out=$(run_stop '')
56+
assert_eq "returns approve on empty preview" "$out" '{"decision":"approve"}'
6757

6858
echo ""
6959
echo "=== pre-tool-use.sh: flag absent → silent allow ==="

0 commit comments

Comments
 (0)