Files
mesh-catalog/modules/claude-code/cmd/claude-code/hook_calls_test.go
T
jochen 08cec8cb1c Route adopt's refusal-counter reset through fsys, and name the wait in the Stop hook's suggestions (issues 1749, 1757)
issue 1749: AdoptName reset both session ids' refusal-counter files with
os.WriteFile, bypassing fsys; on a node whose agent has its own account
(ADR 0266) the module runs as the operator's account and cannot write
into the agent's home directly, so the reset silently failed. writeRefusalCounters
now goes through fsys (MkdirAll then WriteFile), as every other write into
the agent's home does; it is also called from the Stop hook itself, which
already runs as the agent's own account, where fsys is its zero value and
behaves exactly as the plain write did before.

issue 1757: the Stop hook's suggested watched and said calls left out `by`,
which claude_code_wait requires on every op, so a session following the
suggestion word for word was refused. Both now carry "by" from the wait's
own By field (already kept per wait since issue 1629). The SessionStart
hook's suggested adopt call already carried by; added a test for it too,
alongside the Stop hook's two, each extracting the exact JSON the hook
prints and feeding it back to the tool's own dispatch to prove it is
accepted as printed.
2026-10-21 19:35:33 +02:00

140 lines
4.8 KiB
Go

package main
// Every call a hook prints for a session to copy must be accepted by claude_code_wait's own argument check
// (issue 1757): the Stop hook's suggested watched and said calls, and the SessionStart hook's suggested
// adopt call.
import (
"encoding/json"
"strings"
"testing"
"time"
)
// suggestedCall extracts the one JSON object embedded in a hook's message, right after "claude_code_wait "
// — what a session is expected to copy verbatim — and parses it into the arguments a tool call takes.
func suggestedCall(t *testing.T, msg string) map[string]any {
t.Helper()
i := strings.Index(msg, "claude_code_wait ")
if i < 0 {
t.Fatalf("no suggested call in: %s", msg)
}
rest := msg[i+len("claude_code_wait "):]
start := strings.Index(rest, "{")
if start < 0 {
t.Fatalf("no JSON object in: %s", msg)
}
depth, end := 0, -1
for j, c := range rest[start:] {
switch c {
case '{':
depth++
case '}':
depth--
if depth == 0 {
end = start + j + 1
}
}
if end >= 0 {
break
}
}
if end < 0 {
t.Fatalf("unbalanced JSON object in: %s", msg)
}
var args map[string]any
if err := json.Unmarshal([]byte(rest[start:end]), &args); err != nil {
t.Fatalf("the suggested call is not valid JSON: %v (%s)", err, rest[start:end])
}
return args
}
// Issue 1757: the Stop hook's suggested watched call carries `by`, and the tool accepts it as printed.
func TestTheStopHooksSuggestedWatchedCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
if err != nil || len(f.Waits) != 1 {
t.Fatalf("%+v %v", f, err)
}
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], t0.Add(time.Minute))
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
t.Fatalf("the suggested call does not carry by: %s", reason)
}
args := suggestedCall(t, reason)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested watched call, as printed, was refused: %s", text)
}
}
// Issue 1757: the Stop hook's suggested said call (past its bound) carries `by` too.
func TestTheStopHooksSuggestedSaidCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
clock := t0
waiting = &Waiting{Store: store, Now: func() time.Time { return clock }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "verify-0489-test", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 1})
if isErr {
t.Fatalf("add: %s", text)
}
clock = t0.Add(2 * time.Minute) // past its 1-minute bound, for the live tool call too
f, err := readWaitsFile(waitsFilePath(p, "verify-0489-test"))
if err != nil || len(f.Waits) != 1 {
t.Fatalf("%+v %v", f, err)
}
_, reason := waitBlocksStop("verify-0489-test", f.Waits[0], clock)
if !strings.Contains(reason, "\"by\":\"fix-1757\"") {
t.Fatalf("the suggested call does not carry by: %s", reason)
}
args := suggestedCall(t, reason)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested said call, as printed, was refused: %s", text)
}
}
// Issue 1757: the SessionStart hook's suggested adopt call already carries `by` — checked, as asked, so a
// regression here is caught the same way as the Stop hook's.
func TestTheSessionStartHooksSuggestedAdoptCallIsAcceptedByTheTool(t *testing.T) {
p, _ := node(t, "g14")
store := newMemWaits()
waiting = &Waiting{Store: store, Now: func() time.Time { return t0 }}
t.Cleanup(func() { waiting = nil })
s := serve(t, p, &bus{kept: map[string]map[string]any{}}, memConfig{})
text, isErr := s.call(t, "claude_code_wait", map[string]any{"op": "add", "session": "sess-old", "by": "fix-1757@g14",
"what": "the operator's Approve", "read": map[string]any{"address": "mesh-delivery.recheck"}, "bound_minutes": 10})
if isErr {
t.Fatalf("add: %s", text)
}
msg := SessionStartMessage("sess-new", WaitsDirPath(p), t0.Add(time.Minute))
if !strings.Contains(msg, "\"by\":\"fix-1757@g14\"") {
t.Fatalf("the suggested adopt call does not carry by: %s", msg)
}
args := suggestedCall(t, msg)
text, isErr = s.call(t, "claude_code_wait", args)
if isErr {
t.Fatalf("the suggested adopt call, as printed, was refused: %s", text)
}
}