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
254 lines
7.2 KiB
Go
254 lines
7.2 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|