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) } } }