Files
DeepSeek-Reasonix/internal/assembly/boot/effect_hook_checkpoint_test.go
SivanCola ab68ff145f fix(agent): admit reader hook effects through checkpoint barrier
Problem: A reader's hooks can change files without participating in rewind
exclusion, retiring undo, or recording the checkpoint's coverage gap.

Root cause: The invocation caller selects checkpoint admission using only the
concrete tool's read-only flag, even though it already knows that its hook set
may write. The checkpoint owner is correct but this caller bypasses it.

Fix: Include possible tool-hook writes in the two existing admission/coverage
conditions. Reuse the existing writer permit and deferred cleanup. Keep empty
hook readers unchanged and keep tool execution, coverage, and effects separate.

Verification: Compiling original owners fail the actual callback/undo and four
boot regressions; fixed owner/checkpoint and boot race tests pass. Full root
inventory, required builtin/boot, formatting, vet, lint and make check are
qualified separately in the PR. Asynchronous hook process lifetime and the
barrier's existing cancellation limits remain explicit follow-up work.
2026-10-25 08:14:28 +08:00

90 lines
2.9 KiB
Go

package boot
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/hook"
"reasonix/internal/session/control"
"reasonix/internal/state/checkpoint"
"reasonix/internal/state/sessionstore"
)
func TestEffectReadHooksLeaveCheckpointCoverage(t *testing.T) {
for _, stage := range []hook.Event{hook.PreToolUse, hook.PostToolUse} {
for _, proxy := range []bool{false, true} {
name := string(stage) + "/direct"
if proxy {
name = string(stage) + "/capability"
}
t.Run(name, func(t *testing.T) {
home := statedBootHome(t)
root := t.TempDir()
marker := filepath.Join(root, "hook.txt")
writeFile(t, root, "note.txt", "original")
kind := "hook-checkpoint-" + strings.ReplaceAll(t.Name(), "/", "-")
call := scriptedCall{name: "read_file", args: `{"path":"note.txt"}`}
if proxy {
call = scriptedCall{name: "use_capability", args: `{"action":"call","capability_id":"tool:read_file","arguments":{"path":"note.txt"}}`}
}
p := &scriptedCallProvider{calls: []scriptedCall{call}}
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return p, nil })
writeFile(t, root, "reasonix.toml", `default_model = "fixture"
[codegraph]
enabled = false
[[providers]]
name = "fixture"
kind = "`+kind+`"
model = "fixture"
`)
approveWorkspaceAt(t, home, root)
settings := hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{stage: {{Match: "read_file", Command: markerCommand(t, marker)}}}}
raw, err := json.Marshal(settings)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(home, hook.SettingsFilename), raw, 0o600); err != nil {
t.Fatal(err)
}
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: root, Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ctrl.Close() })
ctrl.SetFreshSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := ctrl.Run(ctx, "read the fixture"); err != nil {
t.Fatal(err)
}
if result := p.resultOf(0); !strings.Contains(result, "original") {
t.Fatalf("reader did not retain its normal result: %q", result)
}
if data, err := os.ReadFile(marker); err != nil || string(data) != "ran" {
t.Fatalf("real hook did not write: %q %v", data, err)
}
metas := ctrl.Checkpoints()
found := false
for _, meta := range metas {
for _, gap := range meta.CoverageGaps {
if gap.Reason == checkpoint.GapHookWrite {
found = true
}
}
}
if !found {
t.Fatalf("real hook write omitted from checkpoint coverage: %+v", metas)
}
})
}
}
}