Files
Agent-Native/runtime/loop_hooks_lifecycle_test.go
T
weichen 9758a8d8d0
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Add permission ask loop, lifecycle hook emissions, and Policy type
Why: The N1 harness phase needs the kernel to resolve ask decisions
through the host (paired HITL), emit the full hook lifecycle the plan
promises, and carry mode/rules/session-allow in one named object.

Evidence: go vet clean; go test ./... 24 packages ok, including new
loop_hooks_lifecycle_test.go (lifecycle order, prompt text, ask
allow/deny/legacy paths, stop continuation counts) and three new
policy tests (struct-form parity, session-allow store, nil store).

Impact: hooks adds PostToolBatch and SessionEnd events; loop emits
SessionStart/UserPromptSubmit/PostToolBatch/SessionEnd and PreCompact
lands in AgentSession.Compact; gateTool consults PermissionAsk on ask
and keeps ask=reject when the port is unset; policy gains Policy,
SessionAllows, and MemoryAllows. SubagentStart/Stop emissions are
deferred to the adapters assembly increment (factory site has no bus
access yet).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP2/WP3
2026-10-07 03:01:17 +00:00

254 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runtime
import (
"context"
"strings"
"testing"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
type eventRecorder struct {
seen []hooks.Event
}
func (r *eventRecorder) handler(c hooks.Call) hooks.Outcome {
r.seen = append(r.seen, c.Event)
return hooks.Outcome{}
}
func (r *eventRecorder) has(ev hooks.Event) bool {
for _, e := range r.seen {
if e == ev {
return true
}
}
return false
}
// TestHookLifecycleOrder 断言一次带工具调用的 run 的事件顺序为
// SessionStart → UserPromptSubmit → PreToolUse → PostToolUse →
// PostToolBatch → Stop → SessionEnd。
func TestHookLifecycleOrder(t *testing.T) {
rec := &eventRecorder{}
bus := hooks.New()
bus.On(rec.handler)
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
want := []hooks.Event{hooks.SessionStart, hooks.UserPromptSubmit, hooks.PreToolUse,
hooks.PostToolUse, hooks.PostToolBatch, hooks.Stop, hooks.SessionEnd}
if len(rec.seen) != len(want) {
t.Fatalf("events = %v, want %v", rec.seen, want)
}
for i := range want {
if rec.seen[i] != want[i] {
t.Fatalf("events = %v, want %v", rec.seen, want)
}
}
}
// TestHookUserPromptSubmitCarriesText 断言 UserPromptSubmit 事件带用户输入正文。
func TestHookUserPromptSubmitCarriesText(t *testing.T) {
var got string
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.UserPromptSubmit {
got = c.LastText
}
return hooks.Outcome{}
})
_, err := Run(context.Background(), Config{
Model: "fake",
Hooks: bus,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if got != "ping" {
t.Fatalf("UserPromptSubmit text = %q", got)
}
}
// TestAskPortAllowProceeds:ask 决策经宿主裁决放行后工具照常执行。
func TestAskPortAllowProceeds(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
}
return hooks.Outcome{}
})
sawRequest := false
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PermissionRequest {
sawRequest = true
}
return hooks.Outcome{}
})
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict {
return AskVerdict{Allow: true}
},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("tool executed %d times, want 1", hits)
}
if !sawRequest {
t.Fatal("PermissionRequest event missing")
}
if !strings.Contains(transcriptText(res.Messages), "ran") {
t.Fatalf("tool result missing: %s", transcriptText(res.Messages))
}
}
// TestAskPortDenyPairsResult:宿主拒绝时写配对错误结果,理由取宿主理由。
func TestAskPortDenyPairsResult(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
}
return hooks.Outcome{}
})
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict {
return AskVerdict{Allow: false, Reason: "用户拒绝"}
},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times, want 0", hits)
}
paired := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "用户拒绝" {
paired = true
}
}
if !paired {
t.Fatalf("missing paired deny result: %s", transcriptText(res.Messages))
}
}
// TestAskWithoutPortKeepsLegacyReject:没有裁决端口时 ask 仍按拒绝处理(旧行为)。
func TestAskWithoutPortKeepsLegacyReject(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
}
return hooks.Outcome{}
})
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times, want 0", hits)
}
paired := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "需要确认" {
paired = true
}
}
if !paired {
t.Fatalf("missing paired ask-reject result: %s", transcriptText(res.Messages))
}
}
// TestStopContinuationFlagVisible:续跑后 Stop 事件的 Continuations 计数对下游可见。
func TestStopContinuationFlagVisible(t *testing.T) {
var counts []int
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.Stop {
counts = append(counts, c.Continuations)
return hooks.Outcome{Decision: hooks.Block, Reason: "再试"}
}
return hooks.Outcome{}
})
_, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if len(counts) != 4 {
t.Fatalf("stop events = %v", counts)
}
for i, c := range counts {
if c != i {
t.Fatalf("stop continuations = %v, want 0,1,2,3", counts)
}
}
}