package native import ( "context" "os" "path/filepath" "strings" "sync" "testing" "agent-native/exec" "agent-native/runtime/llm" ) // script 按序回放假助手轮次(对齐 runtime/loop_test 的伪造方式)。 func script(turns ...llm.Message) llm.StreamFunc { var mu sync.Mutex i := 0 return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event { ch := make(chan llm.Event, 4) go func() { defer close(ch) mu.Lock() defer mu.Unlock() if i >= len(turns) { ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}} return } msg := turns[i] i++ if msg.Content != "" { ch <- llm.Event{Kind: llm.KindTextDelta, Text: msg.Content} } ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg} }() return ch } } func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) { dir := t.TempDir() b := New(Options{ ModelOptions: ModelOptions{ Model: "fake", Stream: script( llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"# 交付\n\n工具写入的交付物内容。"}`, }}}, llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd}, ), }, }) var events []exec.ProcessEvent res, err := b.Execute(context.Background(), exec.Request{ WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色", Targets: []string{"output.md"}, OnEvent: func(ev exec.ProcessEvent) { events = append(events, ev) }, }) if err != nil { t.Fatal(err) } if !res.Success { t.Fatalf("want success, got %+v", res) } data, err := os.ReadFile(filepath.Join(dir, "output.md")) if err != nil || !strings.Contains(string(data), "交付物内容") { t.Fatalf("deliverable missing: %v %q", err, data) } if res.Patches["output.md"] == nil { t.Fatal("patches missing output.md") } for _, f := range []string{"TASK.md", "AGENTS.md", "INSTRUCTIONS.md", "execution.log"} { if _, err := os.Stat(filepath.Join(dir, f)); err != nil { t.Errorf("workspace file missing: %s", f) } } sawTool := false for _, ev := range events { if ev.Type == "tool_execution_start" && ev.Tool == "write_file" { sawTool = true } } if !sawTool { t.Errorf("tool process events not forwarded: %+v", events) } } func TestBackendReasoningProfileWritesText(t *testing.T) { dir := t.TempDir() b := New(Options{ ModelOptions: ModelOptions{ Model: "fake", Stream: script( llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd}, ), }, }) res, err := b.Execute(context.Background(), exec.Request{ WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning, Targets: []string{"notes.md"}, }) if err != nil { t.Fatal(err) } if !res.Success { t.Fatalf("want success, got %+v", res) } data, _ := os.ReadFile(filepath.Join(dir, "notes.md")) if !strings.Contains(string(data), "纯文本交付内容") { t.Fatalf("reasoning text not written: %q", data) } } func TestBackendFailClosedWithoutConfig(t *testing.T) { b := New(Options{}) res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}}) if err != nil { t.Fatal(err) } if res.Success || !strings.Contains(res.ErrorReason, "requires") { t.Fatalf("want fail-closed config error, got %+v", res) } res2, _ := New(Options{ ModelOptions: ModelOptions{ APIBase: "http://x", APIKey: "k", }, }).Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}}) if res2.Success || !strings.Contains(res2.ErrorReason, "model") { t.Fatalf("want model required error, got %+v", res2) } res3, _ := New(Options{ ModelOptions: ModelOptions{ Stream: script(), }, }).Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"}) if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") { t.Fatalf("want profile error, got %+v", res3) } }