Files
Agent-Native/runtime/loop_hooks_lifecycle_test.go
T
weichen 6ddd3da8df Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:36:10 +00:00

254 lines
7.3 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"
"git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/hooks"
"git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/runtime/llm"
"git-ssh.u1680832.nyat.app/weichen/Agent-Native.git/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)
}
}
}