Skip to content

Commit 8618bd6

Browse files
committed
Guard TaskStop matching against non-string messages
Coerce .toolUseResult.message with tostring before jq contains() so that unrelated tool results carrying structured messages do not error out and poison the completion pipeline under pipefail. Adds a regression test mixing a completed background task, an SDK task_notification, and an unrelated toolUseResult whose .message is an object.
1 parent f8f5546 commit 8618bd6

2 files changed

Lines changed: 57 additions & 6 deletions

File tree

hooks/lib/loop-bg-tasks.sh

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,15 +330,18 @@ list_pending_background_task_ids() {
330330
# system event for a TaskStop. The id is read from .task_id when
331331
# present; otherwise it is parsed out of the message text as a
332332
# fallback, because some builds record the id only there. The
333-
# `try ... catch empty` keeps a degenerate message (prefix with
334-
# no id, so the regex cannot match) from raising and poisoning
335-
# the pipefail pipeline.
333+
# message is coerced with `tostring` before `contains()` because
334+
# unrelated toolUseResult records may carry a structured (object,
335+
# array, number) `.message`, and `contains()` errors on non-strings;
336+
# without the coercion one bad record would wipe the whole `completed`
337+
# set under pipefail. The `try ... catch empty` keeps a degenerate
338+
# message (prefix with no id, so the regex cannot match) from raising.
336339
jq -r '
337340
select(.toolUseResult != null)
338-
| select((.toolUseResult.message // "")
339-
| contains("Successfully stopped task:"))
341+
| ((.toolUseResult.message // "") | tostring) as $msg
342+
| select($msg | contains("Successfully stopped task:"))
340343
| (.toolUseResult.task_id
341-
// try ((.toolUseResult.message // "")
344+
// try ($msg
342345
| capture("Successfully stopped task: (?<i>[^ (]+)")
343346
| .i) catch empty)
344347
' "$transcript_path" 2>/dev/null

tests/test-stop-hook-bg-allow.sh

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
# find_active_loop default ignores marker -> validators stay isolated
3232
# hook input omits session_id -> cross-session guard fires
3333
# malformed transcript with marker -> marker preserved (fail-closed)
34+
# TaskStop scan with non-string message -> unrelated tool_result does not poison completions
3435
#
3536

3637
set -euo pipefail
@@ -315,6 +316,25 @@ emit_task_stop_result_message_only() {
315316
}'
316317
}
317318

319+
# Unrelated tool_result whose .message is a structured value rather than a
320+
# string. list_pending_background_task_ids scans every .toolUseResult while
321+
# looking for TaskStop records; a non-string .message must not make jq's
322+
# `contains()` error and poison the completion pipeline under pipefail.
323+
emit_tool_result_nonstring_message() {
324+
local tool_use_id="$1"
325+
jq -c -n \
326+
--arg id "$tool_use_id" \
327+
'{
328+
type:"user",
329+
message:{
330+
role:"user",
331+
content:[{tool_use_id:$id, type:"tool_result",
332+
content:[{type:"text", text:"structured result"}]}]
333+
},
334+
toolUseResult:{message:{status:"ok", details:[1,2,3]}, status:"success"}
335+
}'
336+
}
337+
318338
write_transcript() {
319339
local path="$1"
320340
shift
@@ -1670,6 +1690,34 @@ else
16701690
"empty pending list" "got: $TASKSTOP_MSGONLY_HELPER_PENDING"
16711691
fi
16721692

1693+
# ---- TaskStop scan tolerates structured messages on unrelated tool_results ----
1694+
# Source 3 iterates every .toolUseResult and calls `contains()` on .message.
1695+
# An unrelated tool_result whose .message is an object/array/number would make
1696+
# jq error; because the completion pipeline falls back to `completed=""`, that
1697+
# error wipes valid SDK/legacy completions and makes completed tasks pending
1698+
# again. The fix coerces .message to a string before `contains()`.
1699+
echo "Test: helper tolerates non-string .message on unrelated tool_results"
1700+
NONSTRING_MSG_REPO="$TEST_DIR/nonstring_msg"
1701+
create_full_fixture "$NONSTRING_MSG_REPO" > /dev/null
1702+
NONSTRING_MSG_TRANSCRIPT="$TRANSCRIPTS_DIR/nonstring_msg.jsonl"
1703+
NONSTRING_MSG_LAUNCH=$(emit_tool_use_assistant "toolu_nonstring" "Agent" ',"description":"x","prompt":"x"')
1704+
NONSTRING_MSG_RESULT=$(emit_async_agent_launch_result "toolu_nonstring" "agent_nonstring")
1705+
NONSTRING_MSG_COMPLETE=$(emit_sdk_task_notification "agent_nonstring" "toolu_nonstring" "completed")
1706+
NONSTRING_MSG_NOISE=$(emit_tool_result_nonstring_message "toolu_noise")
1707+
write_transcript "$NONSTRING_MSG_TRANSCRIPT" "$NONSTRING_MSG_LAUNCH" "$NONSTRING_MSG_RESULT" "$NONSTRING_MSG_COMPLETE" "$NONSTRING_MSG_NOISE"
1708+
1709+
NONSTRING_MSG_PENDING=$(
1710+
# shellcheck source=/dev/null
1711+
source "$PROJECT_ROOT/hooks/lib/loop-common.sh"
1712+
list_pending_background_task_ids "$NONSTRING_MSG_TRANSCRIPT" 2>/dev/null
1713+
)
1714+
if [[ -z "$NONSTRING_MSG_PENDING" ]]; then
1715+
pass "non-string .message on unrelated tool_result does not poison completions"
1716+
else
1717+
fail "non-string .message on unrelated tool_result does not poison completions" \
1718+
"empty pending list" "got: $NONSTRING_MSG_PENDING"
1719+
fi
1720+
16731721
# ---- cross-session output glob: dead task pruned when transcript has no path ----
16741722
# Fix B - cross-session output glob. On session resume the current
16751723
# transcript has a NEW session id while the task's .output file lives

0 commit comments

Comments
 (0)